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,799 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from ghostcode._version import get_version
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from textual import on
|
|
10
|
+
from textual.app import ComposeResult
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.containers import Horizontal
|
|
13
|
+
from textual.reactive import reactive
|
|
14
|
+
from textual.screen import Screen
|
|
15
|
+
from textual.widgets import Footer, Input, RichLog, Static
|
|
16
|
+
|
|
17
|
+
from ghostcode.ai.factory import create_client
|
|
18
|
+
from ghostcode.ai.registry import PROVIDERS, provider_status
|
|
19
|
+
from ghostcode.agents import AGENTS
|
|
20
|
+
from ghostcode.core.file_ops import autosave_code_blocks
|
|
21
|
+
from ghostcode.core.prompt import build_messages, detect_greeting
|
|
22
|
+
from ghostcode.core.commands import dispatch as cmd_dispatch, list_commands
|
|
23
|
+
from ghostcode.core.memory import memory_summary, count_memories, list_memories, delete_memory
|
|
24
|
+
from ghostcode.core.security import list_deny_rules
|
|
25
|
+
from ghostcode.core.hooks import hook_registry
|
|
26
|
+
from ghostcode.core.tasks import task_manager
|
|
27
|
+
from ghostcode.tools import TOOL_DEFINITIONS
|
|
28
|
+
from ghostcode.tui.dialogs import ApiKeyDialog, DevMenu, PermissionDialog, UpdateDialog
|
|
29
|
+
from ghostcode.tui.widgets import dim_text, render_chat_content
|
|
30
|
+
from ghostcode.utils.config_loader import (
|
|
31
|
+
get_api_key,
|
|
32
|
+
get_default_provider,
|
|
33
|
+
get_last_model,
|
|
34
|
+
set_api_key,
|
|
35
|
+
set_default_provider,
|
|
36
|
+
set_last_model,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class StatusBar(Static):
|
|
41
|
+
provider = reactive("nvidia")
|
|
42
|
+
model = reactive("")
|
|
43
|
+
cwd = reactive("")
|
|
44
|
+
agent = reactive("code")
|
|
45
|
+
|
|
46
|
+
def __init__(self, provider: str = "nvidia", model: str = "", cwd: str = "", agent: str = "code"):
|
|
47
|
+
super().__init__()
|
|
48
|
+
self.provider = provider
|
|
49
|
+
self.model = model
|
|
50
|
+
self.cwd = cwd
|
|
51
|
+
self.agent = agent
|
|
52
|
+
self._build()
|
|
53
|
+
|
|
54
|
+
def _build(self):
|
|
55
|
+
t = Text()
|
|
56
|
+
t.append(" GhostCode ", style="bold cyan")
|
|
57
|
+
t.append("│", style="dim")
|
|
58
|
+
pid = self.provider
|
|
59
|
+
has = bool(get_api_key(pid))
|
|
60
|
+
status = provider_status(pid, has)
|
|
61
|
+
color = {"connected": "green", "needs key": "yellow", "coming soon": "dim"}.get(status, "dim")
|
|
62
|
+
t.append(f" {self.provider.upper()} ", style=f"bold {color}")
|
|
63
|
+
if has:
|
|
64
|
+
t.append("[key] ", style="dim")
|
|
65
|
+
t.append("│", style="dim")
|
|
66
|
+
t.append(f" {self.model} ", style="cyan")
|
|
67
|
+
t.append("│", style="dim")
|
|
68
|
+
ainfo = AGENTS.get(self.agent, {})
|
|
69
|
+
aname = ainfo.get("name", self.agent.upper())
|
|
70
|
+
t.append(f" {aname} ", style="bold magenta")
|
|
71
|
+
t.append("│", style="dim")
|
|
72
|
+
t.append(f" {self.cwd} ", style="dim")
|
|
73
|
+
self.update(t)
|
|
74
|
+
|
|
75
|
+
def watch_provider(self, val):
|
|
76
|
+
self._build()
|
|
77
|
+
|
|
78
|
+
def watch_model(self, val):
|
|
79
|
+
self._build()
|
|
80
|
+
|
|
81
|
+
def watch_cwd(self, val):
|
|
82
|
+
self._build()
|
|
83
|
+
|
|
84
|
+
def watch_agent(self, val):
|
|
85
|
+
self._build()
|
|
86
|
+
|
|
87
|
+
def refresh_status(self):
|
|
88
|
+
self._build()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ChatScreen(Screen):
|
|
92
|
+
MAX_HISTORY = 100
|
|
93
|
+
TRIM_TO = 50
|
|
94
|
+
|
|
95
|
+
BINDINGS = [
|
|
96
|
+
Binding("ctrl+l", "clear", "Clear"),
|
|
97
|
+
Binding("ctrl+q", "quit", "Quit"),
|
|
98
|
+
Binding("escape", "cancel_stream", "Cancel"),
|
|
99
|
+
Binding("tab", "switch_agent", "Agent"),
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
def __init__(self):
|
|
103
|
+
super().__init__()
|
|
104
|
+
self._provider = get_default_provider()
|
|
105
|
+
last_model = get_last_model(self._provider)
|
|
106
|
+
self._model = last_model
|
|
107
|
+
self._client = create_client(self._provider, self._model)
|
|
108
|
+
self._history: list[dict] = []
|
|
109
|
+
self._streaming = False
|
|
110
|
+
self._agent = "code"
|
|
111
|
+
self._agent_order = list(AGENTS.keys())
|
|
112
|
+
self._agent_index = 0
|
|
113
|
+
self._cmd_suggestions = [
|
|
114
|
+
("/init", "create GHOST.md for this project"),
|
|
115
|
+
("/api connect", "show / set API keys"),
|
|
116
|
+
("/api connect <provider>", "enter API key for a provider"),
|
|
117
|
+
("/api connect <provider> <key>", "set API key directly"),
|
|
118
|
+
("/provider", "list providers"),
|
|
119
|
+
("/provider <name>", "switch provider"),
|
|
120
|
+
("/model", "list models for current provider"),
|
|
121
|
+
("/model <name>", "switch model"),
|
|
122
|
+
("/clear", "clear chat history"),
|
|
123
|
+
("/help", "show this help"),
|
|
124
|
+
("/memory", "list/manage persistent memories"),
|
|
125
|
+
("/memory save <name> <content>", "save a memory"),
|
|
126
|
+
("/memory search <query>", "search memories"),
|
|
127
|
+
("/memory delete <name>", "delete a memory"),
|
|
128
|
+
("/memory summary", "show memory stats"),
|
|
129
|
+
("/tasks", "list subagent tasks"),
|
|
130
|
+
("/tasks stop <id>", "stop a task"),
|
|
131
|
+
("/deny", "list security deny rules"),
|
|
132
|
+
("/deny add <pattern>", "add a deny rule"),
|
|
133
|
+
("/deny remove <pattern>", "remove a deny rule"),
|
|
134
|
+
("/hooks", "list active hooks"),
|
|
135
|
+
("/plan", "enter plan mode"),
|
|
136
|
+
("/review", "run code review"),
|
|
137
|
+
("/summary", "summarize session"),
|
|
138
|
+
("/ask", "ask a structured question"),
|
|
139
|
+
("/brief", "toggle brief output mode"),
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
def on_mount(self):
|
|
143
|
+
asyncio.create_task(self._check_update())
|
|
144
|
+
|
|
145
|
+
async def _check_update(self):
|
|
146
|
+
try:
|
|
147
|
+
async with httpx.AsyncClient() as client:
|
|
148
|
+
r = await client.get(
|
|
149
|
+
"https://registry.npmjs.org/ghostcode-canary/latest",
|
|
150
|
+
timeout=5,
|
|
151
|
+
)
|
|
152
|
+
if r.status_code == 200:
|
|
153
|
+
latest = r.json().get("version", "")
|
|
154
|
+
current = get_version()
|
|
155
|
+
if latest and self._is_newer(latest, current):
|
|
156
|
+
await self.app.push_screen(UpdateDialog(current, latest))
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def _is_newer(latest: str, current: str) -> bool:
|
|
162
|
+
try:
|
|
163
|
+
lp = tuple(int(x) for x in latest.split("."))
|
|
164
|
+
cp = tuple(int(x) for x in current.split("."))
|
|
165
|
+
return lp > cp
|
|
166
|
+
except ValueError:
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
@on(Input.Submitted, "#prompt-input")
|
|
170
|
+
async def on_input_submit(self):
|
|
171
|
+
await self.action_submit()
|
|
172
|
+
|
|
173
|
+
def _trim_history(self):
|
|
174
|
+
if len(self._history) <= self.MAX_HISTORY:
|
|
175
|
+
return
|
|
176
|
+
self._history = self._history[-self.TRIM_TO:]
|
|
177
|
+
try:
|
|
178
|
+
log = self.query_one("#chat-log", RichLog)
|
|
179
|
+
log.clear()
|
|
180
|
+
for msg in self._history:
|
|
181
|
+
role = msg["role"]
|
|
182
|
+
content = msg["content"]
|
|
183
|
+
label = "[bold cyan]▎ You[/bold cyan]" if role == "user" else "[bold green]▎ GhostCode[/bold green]"
|
|
184
|
+
log.write(f"\n{label}")
|
|
185
|
+
log.write(render_chat_content(content))
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
def compose(self) -> ComposeResult:
|
|
190
|
+
yield StatusBar(
|
|
191
|
+
provider=self._provider,
|
|
192
|
+
model=self._client.model_name,
|
|
193
|
+
cwd=os.getcwd(),
|
|
194
|
+
agent=self._agent,
|
|
195
|
+
)
|
|
196
|
+
yield RichLog(id="chat-log", highlight=True, markup=True)
|
|
197
|
+
yield Static(id="stream-area")
|
|
198
|
+
with Horizontal(id="input-area"):
|
|
199
|
+
yield Input(id="prompt-input", placeholder="Type a message... (Enter to send)")
|
|
200
|
+
yield Static(id="cmd-suggestions")
|
|
201
|
+
yield Footer()
|
|
202
|
+
|
|
203
|
+
CSS = """
|
|
204
|
+
Screen {
|
|
205
|
+
background: $background;
|
|
206
|
+
}
|
|
207
|
+
StatusBar {
|
|
208
|
+
background: $surface;
|
|
209
|
+
padding: 1 2;
|
|
210
|
+
dock: top;
|
|
211
|
+
border-bottom: tall $primary;
|
|
212
|
+
}
|
|
213
|
+
#chat-log {
|
|
214
|
+
height: 1fr;
|
|
215
|
+
border: none;
|
|
216
|
+
background: $background;
|
|
217
|
+
padding: 1 2;
|
|
218
|
+
overflow-y: auto;
|
|
219
|
+
}
|
|
220
|
+
#input-area {
|
|
221
|
+
height: 4;
|
|
222
|
+
background: $surface;
|
|
223
|
+
border-top: tall $primary;
|
|
224
|
+
padding: 0 1;
|
|
225
|
+
align: center middle;
|
|
226
|
+
}
|
|
227
|
+
#prompt-input {
|
|
228
|
+
width: 1fr;
|
|
229
|
+
height: 3;
|
|
230
|
+
background: $surface;
|
|
231
|
+
color: $text;
|
|
232
|
+
border: round $primary;
|
|
233
|
+
margin: 0;
|
|
234
|
+
}
|
|
235
|
+
#prompt-input:focus {
|
|
236
|
+
border: round $accent;
|
|
237
|
+
}
|
|
238
|
+
#stream-area {
|
|
239
|
+
display: none;
|
|
240
|
+
height: auto;
|
|
241
|
+
max-height: 12;
|
|
242
|
+
background: $surface;
|
|
243
|
+
border-top: tall $primary;
|
|
244
|
+
padding: 1 2;
|
|
245
|
+
margin: 0;
|
|
246
|
+
overflow-y: auto;
|
|
247
|
+
}
|
|
248
|
+
#stream-area.visible {
|
|
249
|
+
display: block;
|
|
250
|
+
}
|
|
251
|
+
#cmd-suggestions {
|
|
252
|
+
display: none;
|
|
253
|
+
height: auto;
|
|
254
|
+
max-height: 10;
|
|
255
|
+
background: $surface;
|
|
256
|
+
border: tall $primary;
|
|
257
|
+
padding: 0 1;
|
|
258
|
+
margin: 0 1;
|
|
259
|
+
overflow-y: auto;
|
|
260
|
+
}
|
|
261
|
+
#cmd-suggestions.visible {
|
|
262
|
+
display: block;
|
|
263
|
+
}
|
|
264
|
+
Footer {
|
|
265
|
+
background: $surface;
|
|
266
|
+
dock: bottom;
|
|
267
|
+
}
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
def _log(self, text: str):
|
|
271
|
+
self.query_one("#chat-log", RichLog).write(text)
|
|
272
|
+
|
|
273
|
+
def _show_info(self, text: str):
|
|
274
|
+
self._log(f"\n[dim]{text}[/dim]")
|
|
275
|
+
|
|
276
|
+
def _persist_state(self):
|
|
277
|
+
set_default_provider(self._provider)
|
|
278
|
+
set_last_model(self._provider, self._client.model_name)
|
|
279
|
+
|
|
280
|
+
@on(Input.Changed, "#prompt-input")
|
|
281
|
+
def on_input_changed(self, event: Input.Changed):
|
|
282
|
+
widget = self.query_one("#cmd-suggestions", Static)
|
|
283
|
+
val = event.value.strip()
|
|
284
|
+
if not val.startswith("/"):
|
|
285
|
+
widget.classes = ""
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
query = val.lower()
|
|
289
|
+
lines = []
|
|
290
|
+
for cmd, desc in self._cmd_suggestions:
|
|
291
|
+
if query == "/" or cmd.lower().startswith(query):
|
|
292
|
+
lines.append(f" [bold cyan]{cmd}[/bold cyan] [dim]{desc}[/dim]")
|
|
293
|
+
|
|
294
|
+
if lines:
|
|
295
|
+
widget.update("\n".join(lines))
|
|
296
|
+
widget.classes = "visible"
|
|
297
|
+
else:
|
|
298
|
+
widget.classes = ""
|
|
299
|
+
|
|
300
|
+
def action_quit(self):
|
|
301
|
+
self.app.exit()
|
|
302
|
+
|
|
303
|
+
async def action_clear(self):
|
|
304
|
+
self._history.clear()
|
|
305
|
+
self._streaming = False
|
|
306
|
+
self.query_one("#chat-log", RichLog).clear()
|
|
307
|
+
self.query_one("#stream-area", Static).classes = ""
|
|
308
|
+
self._show_info("Chat cleared.")
|
|
309
|
+
|
|
310
|
+
async def action_submit(self):
|
|
311
|
+
if self._streaming:
|
|
312
|
+
return
|
|
313
|
+
inp = self.query_one("#prompt-input", Input)
|
|
314
|
+
prompt = inp.value.strip()
|
|
315
|
+
if not prompt:
|
|
316
|
+
return
|
|
317
|
+
inp.value = ""
|
|
318
|
+
self._streaming = True
|
|
319
|
+
|
|
320
|
+
if prompt.startswith("/"):
|
|
321
|
+
if prompt.strip() == "/":
|
|
322
|
+
self._show_help()
|
|
323
|
+
self._streaming = False
|
|
324
|
+
inp.focus()
|
|
325
|
+
return
|
|
326
|
+
await self._handle_command(prompt)
|
|
327
|
+
self._streaming = False
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
has = bool(get_api_key(self._provider))
|
|
331
|
+
info = PROVIDERS.get(self._provider, {})
|
|
332
|
+
if info.get("needs_key") and not has:
|
|
333
|
+
self._log(f"\n[bold yellow]Provider {self._provider.upper()} needs an API key.[/bold yellow]")
|
|
334
|
+
self._log("Type [bold]/api connect[/bold] to see options.")
|
|
335
|
+
self._streaming = False
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
self._history.append({"role": "user", "content": prompt})
|
|
339
|
+
self._trim_history()
|
|
340
|
+
self._log(f"\n[bold cyan]▎ You[/bold cyan]")
|
|
341
|
+
self._log(render_chat_content(prompt))
|
|
342
|
+
|
|
343
|
+
greeting = detect_greeting(prompt)
|
|
344
|
+
if greeting:
|
|
345
|
+
self._log(f"\n[bold green]▎ GhostCode[/bold green]")
|
|
346
|
+
self._log(greeting)
|
|
347
|
+
self._history.append({"role": "assistant", "content": greeting})
|
|
348
|
+
self._trim_history()
|
|
349
|
+
self._streaming = False
|
|
350
|
+
inp.focus()
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
self._log(f"\n[bold green]▎ GhostCode[/bold green]")
|
|
354
|
+
|
|
355
|
+
stream_widget = self.query_one("#stream-area", Static)
|
|
356
|
+
info = PROVIDERS.get(self._provider, {})
|
|
357
|
+
pname = info.get("name", self._provider.upper())
|
|
358
|
+
stream_widget.update(f"[bold cyan]{pname} · {self._client.model_name}[/bold cyan]\n[dim]...thinking[/dim]")
|
|
359
|
+
stream_widget.classes = "visible"
|
|
360
|
+
self._streaming = True
|
|
361
|
+
|
|
362
|
+
messages = build_messages(self._history[:-1], prompt, agent=self._agent)
|
|
363
|
+
full = ""
|
|
364
|
+
has_tools = self._client.supports_tools
|
|
365
|
+
status = ""
|
|
366
|
+
|
|
367
|
+
async def _permission_callback(tool_name: str, args: dict) -> bool:
|
|
368
|
+
result = await self.app.push_screen(PermissionDialog(tool_name, args))
|
|
369
|
+
return bool(result)
|
|
370
|
+
|
|
371
|
+
try:
|
|
372
|
+
gen = (
|
|
373
|
+
self._client.chat(messages, tools=TOOL_DEFINITIONS, permission_callback=_permission_callback)
|
|
374
|
+
if has_tools
|
|
375
|
+
else self._client.generate(messages, stream=True)
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
async for chunk in gen:
|
|
379
|
+
if chunk.startswith("[dim]"):
|
|
380
|
+
status = dim_text(chunk).strip()
|
|
381
|
+
stream_widget.update(
|
|
382
|
+
f"[bold cyan]{pname} · {self._client.model_name}[/bold cyan]\n[dim]{status}[/dim]"
|
|
383
|
+
)
|
|
384
|
+
continue
|
|
385
|
+
full += chunk
|
|
386
|
+
stream_widget.update(f"[bold green]{full}[/bold green]")
|
|
387
|
+
stream_widget.scroll_visible()
|
|
388
|
+
|
|
389
|
+
if full:
|
|
390
|
+
self._log(render_chat_content(full))
|
|
391
|
+
stream_widget.classes = ""
|
|
392
|
+
self._history.append({"role": "assistant", "content": full})
|
|
393
|
+
self._trim_history()
|
|
394
|
+
saved = autosave_code_blocks(full)
|
|
395
|
+
for s in saved:
|
|
396
|
+
self._log(f"\n[dim]auto-saved -> {s['path']} ({s['size']} B)[/dim]")
|
|
397
|
+
except Exception as e:
|
|
398
|
+
stream_widget.update(f"[bold red]Error: {e}[/bold red]")
|
|
399
|
+
finally:
|
|
400
|
+
self._streaming = False
|
|
401
|
+
|
|
402
|
+
inp.focus()
|
|
403
|
+
|
|
404
|
+
async def action_cancel_stream(self):
|
|
405
|
+
if self._streaming:
|
|
406
|
+
self._streaming = False
|
|
407
|
+
sw = self.query_one("#stream-area", Static)
|
|
408
|
+
sw.classes = ""
|
|
409
|
+
self._show_info("Cancelled.")
|
|
410
|
+
|
|
411
|
+
def action_switch_agent(self):
|
|
412
|
+
agents = self._agent_order
|
|
413
|
+
self._agent_index = (self._agent_index + 1) % len(agents)
|
|
414
|
+
self._agent = agents[self._agent_index]
|
|
415
|
+
sb = self.query_one(StatusBar)
|
|
416
|
+
sb.agent = self._agent
|
|
417
|
+
self._show_info(f"Agent → {AGENTS[self._agent]['name']}")
|
|
418
|
+
|
|
419
|
+
async def _cmd_init(self):
|
|
420
|
+
inp = self.query_one("#prompt-input", Input)
|
|
421
|
+
stream_widget = self.query_one("#stream-area", Static)
|
|
422
|
+
stream_widget.update("[bold cyan]Analyzing codebase for GHOST.md...[/bold cyan]")
|
|
423
|
+
stream_widget.classes = "visible"
|
|
424
|
+
self._streaming = True
|
|
425
|
+
|
|
426
|
+
init_prompt = (
|
|
427
|
+
"Analyze this codebase and create a GHOST.md file at the project root. "
|
|
428
|
+
"This file will be given to future AI coding assistants to help them operate in this repository.\n\n"
|
|
429
|
+
"What to include:\n"
|
|
430
|
+
"1. Commands: how to build, lint, test, run a single test, and develop.\n"
|
|
431
|
+
"2. Architecture: high-level code structure so future AIs can be productive quickly.\n"
|
|
432
|
+
"3. Key files: README, configs, and any existing documentation.\n\n"
|
|
433
|
+
"Rules:\n"
|
|
434
|
+
"- Do not repeat yourself or include obvious instructions.\n"
|
|
435
|
+
"- Avoid listing every component or file — focus on the big picture.\n"
|
|
436
|
+
"- Don't make up information. Only include what you find by reading files.\n"
|
|
437
|
+
"- If a GHOST.md already exists, suggest improvements instead of overwriting.\n\n"
|
|
438
|
+
"Prefix the file with:\n"
|
|
439
|
+
"# GHOST.md\n\n"
|
|
440
|
+
"This file provides guidance to GhostCode when working with code in this repository."
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
messages = build_messages([], init_prompt, agent=self._agent)
|
|
444
|
+
full = ""
|
|
445
|
+
try:
|
|
446
|
+
if self._client.supports_tools:
|
|
447
|
+
async for chunk in self._client.chat(messages, tools=TOOL_DEFINITIONS):
|
|
448
|
+
if chunk.startswith("[dim]"):
|
|
449
|
+
stream_widget.update(f"[bold cyan]Exploring...[/bold cyan]\n[dim]{dim_text(chunk)}[/dim]")
|
|
450
|
+
continue
|
|
451
|
+
full += chunk
|
|
452
|
+
stream_widget.update(f"[bold green]{full}[/bold green]")
|
|
453
|
+
stream_widget.scroll_visible()
|
|
454
|
+
else:
|
|
455
|
+
async for chunk in self._client.generate(messages, stream=True):
|
|
456
|
+
full += chunk
|
|
457
|
+
stream_widget.update(f"[bold green]{full}[/bold green]")
|
|
458
|
+
stream_widget.scroll_visible()
|
|
459
|
+
|
|
460
|
+
if full:
|
|
461
|
+
saved = autosave_code_blocks(full)
|
|
462
|
+
stream_widget.classes = ""
|
|
463
|
+
for s in saved:
|
|
464
|
+
self._log(f"\n[dim]GHOST.md saved -> {s['path']} ({s['size']} B)[/dim]")
|
|
465
|
+
if not saved:
|
|
466
|
+
self._log(f"\n[dim]GHOST.md created (check project root)[/dim]")
|
|
467
|
+
except Exception as e:
|
|
468
|
+
stream_widget.update(f"[bold red]Error: {e}[/bold red]")
|
|
469
|
+
finally:
|
|
470
|
+
self._streaming = False
|
|
471
|
+
inp.focus()
|
|
472
|
+
|
|
473
|
+
async def _handle_command(self, cmd: str):
|
|
474
|
+
parts = cmd.split()
|
|
475
|
+
root = parts[0].lower()
|
|
476
|
+
|
|
477
|
+
if root == "/clear":
|
|
478
|
+
await self.action_clear()
|
|
479
|
+
|
|
480
|
+
elif root == "/help" or root == "/?":
|
|
481
|
+
self._show_help()
|
|
482
|
+
|
|
483
|
+
elif root == "/model":
|
|
484
|
+
if len(parts) == 1:
|
|
485
|
+
self._show_model_list()
|
|
486
|
+
else:
|
|
487
|
+
self._model = parts[1]
|
|
488
|
+
self._client = create_client(self._provider, self._model)
|
|
489
|
+
sb = self.query_one(StatusBar)
|
|
490
|
+
sb.model = self._client.model_name
|
|
491
|
+
self._persist_state()
|
|
492
|
+
self._show_info(f"Model → {self._client.model_name}")
|
|
493
|
+
|
|
494
|
+
elif root == "/provider":
|
|
495
|
+
if len(parts) == 1:
|
|
496
|
+
self._show_api_list()
|
|
497
|
+
else:
|
|
498
|
+
pid = parts[1].lower()
|
|
499
|
+
if pid not in PROVIDERS:
|
|
500
|
+
self._show_info(f"Unknown provider: {pid}")
|
|
501
|
+
return
|
|
502
|
+
self._client = create_client(pid, PROVIDERS[pid]["default_model"])
|
|
503
|
+
self._provider = pid
|
|
504
|
+
self._model = self._client.model_name
|
|
505
|
+
sb = self.query_one(StatusBar)
|
|
506
|
+
sb.provider = pid
|
|
507
|
+
sb.model = self._client.model_name
|
|
508
|
+
self._persist_state()
|
|
509
|
+
self._show_model_list()
|
|
510
|
+
|
|
511
|
+
elif root == "/api":
|
|
512
|
+
await self._handle_api(parts[1:])
|
|
513
|
+
|
|
514
|
+
elif root == "/dev":
|
|
515
|
+
await self._show_dev_menu()
|
|
516
|
+
|
|
517
|
+
elif root == "/update":
|
|
518
|
+
await self._check_update()
|
|
519
|
+
|
|
520
|
+
elif root == "/init":
|
|
521
|
+
await self._cmd_init()
|
|
522
|
+
|
|
523
|
+
elif root == "/memory":
|
|
524
|
+
await self._cmd_memory(parts[1:])
|
|
525
|
+
|
|
526
|
+
elif root == "/tasks":
|
|
527
|
+
await self._cmd_tasks(parts[1:])
|
|
528
|
+
|
|
529
|
+
elif root == "/deny":
|
|
530
|
+
await self._cmd_deny(parts[1:])
|
|
531
|
+
|
|
532
|
+
elif root == "/hooks":
|
|
533
|
+
self._cmd_hooks()
|
|
534
|
+
|
|
535
|
+
elif root == "/plan":
|
|
536
|
+
await self._cmd_plan()
|
|
537
|
+
|
|
538
|
+
elif root == "/review":
|
|
539
|
+
await self._cmd_review()
|
|
540
|
+
|
|
541
|
+
elif root == "/summary":
|
|
542
|
+
await self._cmd_summary()
|
|
543
|
+
|
|
544
|
+
elif root == "/ask":
|
|
545
|
+
self._log(
|
|
546
|
+
"\n"
|
|
547
|
+
"[bold]Ask a question:[/bold]\n"
|
|
548
|
+
" [dim]Usage: simply type your question directly. If you want multiple-choice options, list them like:[/dim]\n"
|
|
549
|
+
" [dim] Option A: ...[/dim]\n"
|
|
550
|
+
" [dim] Option B: ...[/dim]"
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
elif root == "/brief":
|
|
554
|
+
self._log("\n[dim]Brief mode toggled. (Placeholder — brief output preference not yet persisted.)[/dim]")
|
|
555
|
+
|
|
556
|
+
else:
|
|
557
|
+
self._show_info(f"Unknown: {root} (/help)")
|
|
558
|
+
|
|
559
|
+
async def _handle_api(self, args: list[str]):
|
|
560
|
+
if not args or args[0] != "connect":
|
|
561
|
+
self._show_api_list()
|
|
562
|
+
return
|
|
563
|
+
|
|
564
|
+
if len(args) == 1:
|
|
565
|
+
self._show_api_list()
|
|
566
|
+
elif len(args) == 2:
|
|
567
|
+
pid = args[1].lower()
|
|
568
|
+
if pid not in PROVIDERS:
|
|
569
|
+
self._show_info(f"Unknown provider: {pid}")
|
|
570
|
+
return
|
|
571
|
+
info = PROVIDERS[pid]
|
|
572
|
+
if info.get("status") == "coming_soon":
|
|
573
|
+
self._show_info(f"[dim]{info['name']} is coming soon.[/dim]")
|
|
574
|
+
return
|
|
575
|
+
if not info["needs_key"]:
|
|
576
|
+
self._show_info(f"[dim]{info['name']} doesn't need an API key (local).[/dim]")
|
|
577
|
+
return
|
|
578
|
+
dialog = ApiKeyDialog(pid, info["name"])
|
|
579
|
+
key = await self.app.push_screen(dialog)
|
|
580
|
+
if key:
|
|
581
|
+
set_api_key(pid, key)
|
|
582
|
+
self._provider = pid
|
|
583
|
+
self._client = create_client(pid, self._model)
|
|
584
|
+
sb = self.query_one(StatusBar)
|
|
585
|
+
sb.provider = pid
|
|
586
|
+
sb.model = self._client.model_name
|
|
587
|
+
self._persist_state()
|
|
588
|
+
self._show_info(f"[green]{info['name']} API key saved.[/green]")
|
|
589
|
+
elif len(args) >= 3:
|
|
590
|
+
pid = args[1].lower()
|
|
591
|
+
key = " ".join(args[2:])
|
|
592
|
+
if pid not in PROVIDERS:
|
|
593
|
+
self._show_info(f"Unknown provider: {pid}")
|
|
594
|
+
return
|
|
595
|
+
set_api_key(pid, key)
|
|
596
|
+
self._provider = pid
|
|
597
|
+
self._client = create_client(pid, self._model)
|
|
598
|
+
sb = self.query_one(StatusBar)
|
|
599
|
+
sb.provider = pid
|
|
600
|
+
sb.model = self._client.model_name
|
|
601
|
+
self._persist_state()
|
|
602
|
+
info = PROVIDERS[pid]
|
|
603
|
+
self._show_info(f"[green]{info['name']} API key saved.[/green]")
|
|
604
|
+
|
|
605
|
+
async def _show_dev_menu(self):
|
|
606
|
+
await self.app.push_screen(DevMenu())
|
|
607
|
+
|
|
608
|
+
def _show_api_list(self):
|
|
609
|
+
table = Table(show_edge=False, show_header=False, padding=(0, 1))
|
|
610
|
+
table.add_column(style="bold", width=14)
|
|
611
|
+
table.add_column(width=30)
|
|
612
|
+
table.add_column(width=14)
|
|
613
|
+
|
|
614
|
+
for pid, info in PROVIDERS.items():
|
|
615
|
+
key = bool(get_api_key(pid))
|
|
616
|
+
st = provider_status(pid, key)
|
|
617
|
+
status_colors = {
|
|
618
|
+
"connected": "green",
|
|
619
|
+
"needs key": "yellow",
|
|
620
|
+
"coming soon": "dim",
|
|
621
|
+
"ready": "green",
|
|
622
|
+
}
|
|
623
|
+
sc = status_colors.get(st, "dim")
|
|
624
|
+
name = info["name"]
|
|
625
|
+
desc = info["description"]
|
|
626
|
+
table.add_row(
|
|
627
|
+
f"[bold]{name}[/bold]",
|
|
628
|
+
f"[dim]{desc}[/dim]",
|
|
629
|
+
f"[{sc}]{st}[/{sc}]",
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
self._log("\n" + "[bold]Available providers:[/bold]")
|
|
633
|
+
self._log(table)
|
|
634
|
+
self._log("[dim]Usage: /api connect <provider> or /api connect <provider> <key>[/dim]")
|
|
635
|
+
|
|
636
|
+
async def _cmd_memory(self, args: list[str]):
|
|
637
|
+
if not args:
|
|
638
|
+
mems = list_memories()
|
|
639
|
+
if not mems:
|
|
640
|
+
self._show_info("No memories saved. Use /memory save <name> <content> to create one.")
|
|
641
|
+
else:
|
|
642
|
+
self._log("\n" + "[bold]Memories:[/bold]")
|
|
643
|
+
for m in mems:
|
|
644
|
+
meta = m.get("metadata", {})
|
|
645
|
+
mtype = meta.get("type", "?")
|
|
646
|
+
pinned = " [dim]📌[/dim]" if meta.get("pinned") else ""
|
|
647
|
+
self._log(f" [bold cyan]{m['name']}[/bold cyan] [dim]({mtype}{pinned})[/dim] — {m.get('description', '')[:80]}")
|
|
648
|
+
return
|
|
649
|
+
|
|
650
|
+
sub = args[0].lower()
|
|
651
|
+
if sub == "save" and len(args) >= 3:
|
|
652
|
+
name = args[1]
|
|
653
|
+
content = " ".join(args[2:])
|
|
654
|
+
from ghostcode.core.memory import save_memory
|
|
655
|
+
result = save_memory(name, content)
|
|
656
|
+
self._show_info(f"Memory saved: [bold cyan]{result['slug']}[/bold cyan] ({result['type']}, {result['bytes']}B)")
|
|
657
|
+
elif sub == "search" and len(args) >= 2:
|
|
658
|
+
query = " ".join(args[1:])
|
|
659
|
+
from ghostcode.core.memory import search_memory
|
|
660
|
+
results = search_memory(query)
|
|
661
|
+
if not results:
|
|
662
|
+
self._show_info(f"No memories matching '{query}'")
|
|
663
|
+
else:
|
|
664
|
+
self._log(f"\n[bold]Memories matching '{query}':[/bold]")
|
|
665
|
+
for r in results:
|
|
666
|
+
self._log(f" [bold cyan]{r['name']}[/bold cyan] — {r.get('description', '')[:100]}")
|
|
667
|
+
elif sub == "delete" and len(args) >= 2:
|
|
668
|
+
name = args[1]
|
|
669
|
+
ok = delete_memory(name)
|
|
670
|
+
self._show_info(f"Memory '{name}' {'deleted' if ok else 'not found'}")
|
|
671
|
+
elif sub == "summary":
|
|
672
|
+
s = memory_summary()
|
|
673
|
+
self._log(f"\n[bold]Memory Summary:[/bold] {s['total']} total")
|
|
674
|
+
for t, c in s.get("by_type", {}).items():
|
|
675
|
+
self._log(f" [cyan]{t}:[/cyan] {c}")
|
|
676
|
+
else:
|
|
677
|
+
self._show_info("Usage: /memory [save|search|delete|summary] ...")
|
|
678
|
+
|
|
679
|
+
async def _cmd_tasks(self, args: list[str]):
|
|
680
|
+
if args and args[0] == "stop" and len(args) >= 2:
|
|
681
|
+
tid = args[1]
|
|
682
|
+
ok = task_manager.stop(tid)
|
|
683
|
+
self._show_info(f"Task {tid} {'stopped' if ok else 'not found or already complete'}")
|
|
684
|
+
return
|
|
685
|
+
|
|
686
|
+
tasks = task_manager.list_tasks()
|
|
687
|
+
if not tasks:
|
|
688
|
+
self._show_info("No tasks. Tasks are created automatically when the AI spawns subagents.")
|
|
689
|
+
else:
|
|
690
|
+
self._log("\n" + "[bold]Tasks:[/bold]")
|
|
691
|
+
for t in tasks:
|
|
692
|
+
color = {"completed": "green", "running": "cyan", "pending": "yellow", "failed": "red", "stopped": "dim"}.get(t["status"], "dim")
|
|
693
|
+
dur = f" ({t['duration_ms']}ms)" if t["duration_ms"] else ""
|
|
694
|
+
self._log(f" [{color}]{t['id']}[/{color}] {t['description']} [dim]{t['status']}{dur}[/dim]")
|
|
695
|
+
|
|
696
|
+
async def _cmd_deny(self, args: list[str]):
|
|
697
|
+
if not args:
|
|
698
|
+
rules = list_deny_rules()
|
|
699
|
+
if not rules:
|
|
700
|
+
self._show_info("No deny rules configured.")
|
|
701
|
+
else:
|
|
702
|
+
self._log("\n" + "[bold]Deny rules:[/bold]")
|
|
703
|
+
for r in rules:
|
|
704
|
+
self._log(f" [bold yellow]{r['pattern']}[/bold yellow] [dim]{r.get('reason', '')}[/dim]")
|
|
705
|
+
return
|
|
706
|
+
|
|
707
|
+
sub = args[0].lower()
|
|
708
|
+
if sub == "add" and len(args) >= 2:
|
|
709
|
+
pattern = args[1]
|
|
710
|
+
reason = " ".join(args[2:]) if len(args) > 2 else ""
|
|
711
|
+
from ghostcode.core.security import add_deny_rule
|
|
712
|
+
add_deny_rule(pattern, reason)
|
|
713
|
+
self._show_info(f"Deny rule added: [bold yellow]{pattern}[/bold yellow]")
|
|
714
|
+
elif sub == "remove" and len(args) >= 2:
|
|
715
|
+
pattern = args[1]
|
|
716
|
+
from ghostcode.core.security import remove_deny_rule
|
|
717
|
+
ok = remove_deny_rule(pattern)
|
|
718
|
+
self._show_info(f"Deny rule {'removed' if ok else 'not found'}: {pattern}")
|
|
719
|
+
else:
|
|
720
|
+
self._show_info("Usage: /deny [add <pattern> [reason]|remove <pattern>]")
|
|
721
|
+
|
|
722
|
+
def _cmd_hooks(self):
|
|
723
|
+
s = hook_registry.summary()
|
|
724
|
+
self._log(f"\n[bold]Hooks:[/bold]")
|
|
725
|
+
self._log(f" Before tool: {s['before_tool']}")
|
|
726
|
+
self._log(f" After tool: {s['after_tool']}")
|
|
727
|
+
self._log(f" Stop hook: {s['stop_hook']}")
|
|
728
|
+
self._log("[dim]Configure via .ghostcode/hooks.json[/dim]")
|
|
729
|
+
|
|
730
|
+
async def _cmd_plan(self):
|
|
731
|
+
self.action_switch_agent()
|
|
732
|
+
self._show_info("[bold cyan]Plan mode[/bold cyan] — explore, analyze, and design before implementing.")
|
|
733
|
+
self._log("[dim]Use the Plan agent to search code, analyze architecture, and create implementation plans. Switch back with Tab.[/dim]")
|
|
734
|
+
|
|
735
|
+
async def _cmd_review(self):
|
|
736
|
+
self._log("\n[bold]Code Review[/bold]")
|
|
737
|
+
self._log("[dim]Run /init to create GHOST.md, then ask the AI to review specific code or files.[/dim]")
|
|
738
|
+
|
|
739
|
+
async def _cmd_summary(self):
|
|
740
|
+
total_msgs = len(self._history)
|
|
741
|
+
mems = count_memories()
|
|
742
|
+
self._log(f"\n[bold]Session Summary[/bold]")
|
|
743
|
+
self._log(f" Messages: {total_msgs}")
|
|
744
|
+
self._log(f" Memories: {mems}")
|
|
745
|
+
self._log(f" Agent: {AGENTS[self._agent]['name']}")
|
|
746
|
+
self._log(f" Provider: {self._provider.upper()} · {self._client.model_name}")
|
|
747
|
+
|
|
748
|
+
def _show_help(self):
|
|
749
|
+
self._log(
|
|
750
|
+
"\n"
|
|
751
|
+
"[bold]Commands:[/bold]\n"
|
|
752
|
+
" [bold cyan]/init[/bold cyan] create GHOST.md for this project\n"
|
|
753
|
+
" [bold cyan]/api connect[/bold cyan] show providers\n"
|
|
754
|
+
" [bold cyan]/api connect[/bold cyan] [green]<provider>[/green] enter API key\n"
|
|
755
|
+
" [bold cyan]/api connect[/bold cyan] [green]<provider> <key>[/green] set key directly\n"
|
|
756
|
+
" [bold cyan]/model[/bold cyan] show models\n"
|
|
757
|
+
" [bold cyan]/model[/bold cyan] [green]<name>[/green] switch model\n"
|
|
758
|
+
" [bold cyan]/provider[/bold cyan] show providers\n"
|
|
759
|
+
" [bold cyan]/provider[/bold cyan] [green]<name>[/green] switch provider\n"
|
|
760
|
+
" [bold cyan]/clear[/bold cyan] clear chat\n"
|
|
761
|
+
" [bold cyan]/memory[/bold cyan] list/manage memories\n"
|
|
762
|
+
" [bold cyan]/memory save[/bold cyan] [green]<name> <content>[/green] save a memory\n"
|
|
763
|
+
" [bold cyan]/memory search[/bold cyan] [green]<query>[/green] search memories\n"
|
|
764
|
+
" [bold cyan]/memory delete[/bold cyan] [green]<name>[/green] delete a memory\n"
|
|
765
|
+
" [bold cyan]/memory summary[/bold cyan] show memory stats\n"
|
|
766
|
+
" [bold cyan]/tasks[/bold cyan] list subagent tasks\n"
|
|
767
|
+
" [bold cyan]/tasks stop[/bold cyan] [green]<id>[/green] stop a task\n"
|
|
768
|
+
" [bold cyan]/deny[/bold cyan] list deny rules\n"
|
|
769
|
+
" [bold cyan]/deny add[/bold cyan] [green]<pattern>[/green] add deny rule\n"
|
|
770
|
+
" [bold cyan]/deny remove[/bold cyan] [green]<pattern>[/green] remove deny rule\n"
|
|
771
|
+
" [bold cyan]/hooks[/bold cyan] show hook status\n"
|
|
772
|
+
" [bold cyan]/plan[/bold cyan] enter plan mode (Tab)\n"
|
|
773
|
+
" [bold cyan]/review[/bold cyan] init code review\n"
|
|
774
|
+
" [bold cyan]/summary[/bold cyan] session summary\n"
|
|
775
|
+
" [bold cyan]/ask[/bold cyan] show ask usage\n"
|
|
776
|
+
" [bold cyan]/brief[/bold cyan] toggle brief output mode\n"
|
|
777
|
+
" [bold cyan]/help[/bold cyan] this help" + "\n"
|
|
778
|
+
"\n"
|
|
779
|
+
"[dim]Tab — switch agent | Ctrl+L — clear | Ctrl+Q — quit | Esc — cancel[/dim]"
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
def _show_model_list(self):
|
|
783
|
+
info = PROVIDERS.get(self._provider, {})
|
|
784
|
+
models = info.get("models", [])
|
|
785
|
+
name = info.get("name", self._provider.upper())
|
|
786
|
+
current = self._client.model_name
|
|
787
|
+
|
|
788
|
+
self._log(f"\n[bold]{name} models[/bold] [dim](current: {current})[/dim]")
|
|
789
|
+
|
|
790
|
+
table = Table(show_edge=False, show_header=False, padding=(0, 1))
|
|
791
|
+
table.add_column(width=2)
|
|
792
|
+
table.add_column(width=50)
|
|
793
|
+
|
|
794
|
+
for m in models:
|
|
795
|
+
marker = "[green]→[/green]" if m == current else " "
|
|
796
|
+
table.add_row(marker, m)
|
|
797
|
+
|
|
798
|
+
self._log(table)
|
|
799
|
+
self._log("[dim]Usage: /model <model-name>[/dim]")
|