linkgravity 1.0.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 +21 -0
- package/README.md +114 -0
- package/bin/cli.js +278 -0
- package/bin/setup.js +260 -0
- package/hooks/hook.py +60 -0
- package/hooks/stop_hook.py +64 -0
- package/npm-scripts/postinstall.js +62 -0
- package/npm-scripts/prepare.js +45 -0
- package/npm-scripts/register-hook.js +182 -0
- package/npm-scripts/run-dev.js +9 -0
- package/npm-scripts/venv-paths.js +45 -0
- package/package.json +59 -0
- package/requirements.txt +13 -0
- package/src/api/server.py +48 -0
- package/src/api/ui_routes.py +340 -0
- package/src/api/voice_routes.py +94 -0
- package/src/approval/command_parser.py +62 -0
- package/src/approval/tool_formatter.py +68 -0
- package/src/cogs/general_cog.py +287 -0
- package/src/cogs/voice/__init__.py +0 -0
- package/src/cogs/voice/enrollment.py +436 -0
- package/src/cogs/voice/stt_session.py +121 -0
- package/src/cogs/voice_cog.py +573 -0
- package/src/config.py +123 -0
- package/src/core/agy_runner.py +380 -0
- package/src/core/atomic_io.py +31 -0
- package/src/core/logger.py +28 -0
- package/src/core/session_manager.py +126 -0
- package/src/handlers/message_router.py +16 -0
- package/src/handlers/thread_reply.py +165 -0
- package/src/main.py +313 -0
- package/src/messengers/base.py +105 -0
- package/src/messengers/discord_adapter.py +240 -0
- package/src/messengers/registry.py +19 -0
- package/src/services/audio_service.py +67 -0
- package/src/services/discord_helpers.py +95 -0
- package/src/services/discord_mcp.py +50 -0
- package/src/services/response.py +51 -0
- package/src/services/streaming.py +199 -0
- package/src/utils/utils.py +40 -0
- package/voice-service/index.js +1048 -0
- package/voice-service/package-lock.json +1880 -0
- package/voice-service/package.json +24 -0
package/src/config.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from core.atomic_io import atomic_write_json, safe_load_json
|
|
5
|
+
|
|
6
|
+
WORKSPACE_DIR = Path.home() / ".gemini" / "linkgravity"
|
|
7
|
+
WORKSPACE_DIR.mkdir(parents=True, exist_ok=True)
|
|
8
|
+
DATA_DIR = WORKSPACE_DIR / "data"
|
|
9
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
10
|
+
|
|
11
|
+
LGY_CONFIG_FILE = WORKSPACE_DIR / "lgy.json"
|
|
12
|
+
|
|
13
|
+
DEFAULT_LGY_CONFIG = {
|
|
14
|
+
"discord_token": "",
|
|
15
|
+
"session_scopes": [],
|
|
16
|
+
"allowed_user_ids": "",
|
|
17
|
+
"wake_words": "Jarvis",
|
|
18
|
+
"active_timer": 60,
|
|
19
|
+
"voice_threshold": 3000,
|
|
20
|
+
"tts_voice": "ko-KR-SunHiNeural",
|
|
21
|
+
"tts_enabled": True,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _PrintLogger:
|
|
26
|
+
"""logger.py hasn't been initialized yet at this point in config.py's
|
|
27
|
+
own load order, so this is a minimal stand-in just for the (rare)
|
|
28
|
+
lgy.json-corrupted case."""
|
|
29
|
+
|
|
30
|
+
def error(self, msg):
|
|
31
|
+
print(f"[config] {msg}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_bot_settings():
|
|
35
|
+
data = safe_load_json(LGY_CONFIG_FILE, DEFAULT_LGY_CONFIG.copy(), logger=_PrintLogger())
|
|
36
|
+
for k, v in DEFAULT_LGY_CONFIG.items():
|
|
37
|
+
data.setdefault(k, v)
|
|
38
|
+
return data
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def save_bot_settings(data):
|
|
42
|
+
atomic_write_json(LGY_CONFIG_FILE, data)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
bot_settings = load_bot_settings()
|
|
46
|
+
|
|
47
|
+
from core.logger import init_logger
|
|
48
|
+
from core.session_manager import SessionManager
|
|
49
|
+
|
|
50
|
+
logger = init_logger(WORKSPACE_DIR)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
DISCORD_TOKEN = bot_settings.get("discord_token", "")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse_session_scopes(raw_scopes) -> dict:
|
|
57
|
+
"""
|
|
58
|
+
Each entry: {"guild_id": "...", "channel_ids": ["...", ...]}.
|
|
59
|
+
An empty/missing channel_ids means "the whole server is allowed" -
|
|
60
|
+
otherwise only the listed channels within that server are allowed.
|
|
61
|
+
Returns {guild_id: frozenset_of_channel_ids_or_None}.
|
|
62
|
+
"""
|
|
63
|
+
scopes = {}
|
|
64
|
+
for entry in raw_scopes or []:
|
|
65
|
+
try:
|
|
66
|
+
guild_id = int(entry["guild_id"])
|
|
67
|
+
except (KeyError, TypeError, ValueError):
|
|
68
|
+
continue
|
|
69
|
+
channel_ids = entry.get("channel_ids") or []
|
|
70
|
+
channel_ids = {int(c) for c in channel_ids if str(c).strip()}
|
|
71
|
+
scopes[guild_id] = frozenset(channel_ids) if channel_ids else None
|
|
72
|
+
return scopes
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
SESSION_SCOPES = _parse_session_scopes(bot_settings.get("session_scopes"))
|
|
76
|
+
ALLOWED_IDS = set(int(x) for x in bot_settings.get("allowed_user_ids", "").split(",") if x.strip())
|
|
77
|
+
TTS_VOICE = bot_settings.get("tts_voice", "ko-KR-SunHiNeural")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_allowed_session_channel(channel) -> bool:
|
|
81
|
+
"""True if a new agy session may be started from this channel (via
|
|
82
|
+
/new). A channel is allowed if its server is in SESSION_SCOPES AND
|
|
83
|
+
either that server has no channel restriction (whole-server access)
|
|
84
|
+
or this specific channel is in its allowed list."""
|
|
85
|
+
guild = getattr(channel, "guild", None)
|
|
86
|
+
if not guild or guild.id not in SESSION_SCOPES:
|
|
87
|
+
return False
|
|
88
|
+
allowed_channels = SESSION_SCOPES[guild.id]
|
|
89
|
+
return allowed_channels is None or channel.id in allowed_channels
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
TMP_FILE_DIR = WORKSPACE_DIR / "tmp-files"
|
|
93
|
+
TMP_VOICE_DIR = WORKSPACE_DIR / "tmp-voice"
|
|
94
|
+
# Per-user wake-word recordings + built .rpw reference (see
|
|
95
|
+
# EnrollmentManager in cogs/voice/enrollment.py). No folder/.rpw yet means
|
|
96
|
+
# "not enrolled" - falls back to transcribing everything and matching
|
|
97
|
+
# bot_settings["wake_words"] as text.
|
|
98
|
+
WAKE_REF_DIR = WORKSPACE_DIR / "wake_refs"
|
|
99
|
+
|
|
100
|
+
TMP_FILE_DIR.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
TMP_VOICE_DIR.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
WAKE_REF_DIR.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
|
|
104
|
+
MAX_EMBED_LEN = 1900
|
|
105
|
+
STREAM_RATE_LIMIT_SEC = 0.5
|
|
106
|
+
PERSISTENT_FILE = DATA_DIR / "persistent_tools.json"
|
|
107
|
+
SESSION_FILE = DATA_DIR / "sessions.json"
|
|
108
|
+
|
|
109
|
+
EMBED_COLOR = 0x5865F2
|
|
110
|
+
|
|
111
|
+
MODEL_CHOICES = {
|
|
112
|
+
"flash": "Gemini 3.5 Flash",
|
|
113
|
+
"flash_lite": "Gemini 3.5 Flash Lite",
|
|
114
|
+
"pro": "Gemini 3.1 Pro",
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
AGY_BIN = os.getenv("AGY_BIN_PATH", str(Path.home() / ".local/bin/agy"))
|
|
118
|
+
|
|
119
|
+
session_manager = SessionManager(DATA_DIR)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def allowed(user_id: int) -> bool:
|
|
123
|
+
return not ALLOWED_IDS or user_id in ALLOWED_IDS
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import functools
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import signal
|
|
6
|
+
|
|
7
|
+
from config import logger
|
|
8
|
+
|
|
9
|
+
active_processes = {}
|
|
10
|
+
agy_start_lock = asyncio.Lock()
|
|
11
|
+
# Threads killed intentionally - agy's SIGTERM exit code isn't reliable enough to tell otherwise.
|
|
12
|
+
_intentionally_stopped = set()
|
|
13
|
+
|
|
14
|
+
# Forced stdout buffer size (see _find_libstdbuf) - big enough for one write, small enough not to delay polling.
|
|
15
|
+
_STDOUT_BUFFER_SIZE = 65536
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@functools.lru_cache(maxsize=1)
|
|
19
|
+
def _find_libstdbuf() -> str | None:
|
|
20
|
+
"""Find libstdbuf.so, the shared library `stdbuf` LD_PRELOADs. Linux-only
|
|
21
|
+
(no macOS/Windows equivalent implemented) - returns None there too.
|
|
22
|
+
|
|
23
|
+
agy runs commands under a PTY, so glibc line-buffers instead of
|
|
24
|
+
fully-buffering stdout - agy's completion-detection misreads the gap
|
|
25
|
+
between line writes as "done," truncating multi-line output to its
|
|
26
|
+
first line despite exit code 0. LD_PRELOADing this forces full
|
|
27
|
+
buffering instead. Returns None (no fix applied) if not found.
|
|
28
|
+
"""
|
|
29
|
+
candidates = [
|
|
30
|
+
"/usr/lib/x86_64-linux-gnu/coreutils/libstdbuf.so", # Debian/Ubuntu
|
|
31
|
+
"/usr/lib/aarch64-linux-gnu/coreutils/libstdbuf.so",
|
|
32
|
+
"/usr/libexec/coreutils/libstdbuf.so", # Fedora/RHEL
|
|
33
|
+
"/usr/lib/coreutils/libstdbuf.so", # Arch
|
|
34
|
+
"/usr/lib/libstdbuf.so",
|
|
35
|
+
]
|
|
36
|
+
for path in candidates:
|
|
37
|
+
if os.path.isfile(path):
|
|
38
|
+
return path
|
|
39
|
+
|
|
40
|
+
# lru_cache: only runs once per process.
|
|
41
|
+
try:
|
|
42
|
+
import subprocess
|
|
43
|
+
|
|
44
|
+
result = subprocess.run(
|
|
45
|
+
["find", "/usr", "-name", "libstdbuf.so"],
|
|
46
|
+
capture_output=True,
|
|
47
|
+
text=True,
|
|
48
|
+
timeout=5,
|
|
49
|
+
)
|
|
50
|
+
found = [line for line in result.stdout.strip().splitlines() if line]
|
|
51
|
+
if found:
|
|
52
|
+
return found[0]
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
logger.warning(
|
|
57
|
+
"[AGY ENV] libstdbuf.so not found - run_command output for "
|
|
58
|
+
"multi-line commands may come back truncated. Add its path to "
|
|
59
|
+
"_find_libstdbuf()'s candidates list if coreutils is installed "
|
|
60
|
+
"somewhere nonstandard."
|
|
61
|
+
)
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def stop_active_process(thread_id: str) -> bool:
|
|
66
|
+
"""Kill the agy subprocess for this thread, if any. Also used when a
|
|
67
|
+
new voice utterance interrupts a still-in-flight turn. Returns
|
|
68
|
+
whether a process was actually found and signaled.
|
|
69
|
+
"""
|
|
70
|
+
target_proc = active_processes.get(thread_id)
|
|
71
|
+
if not target_proc:
|
|
72
|
+
return False
|
|
73
|
+
_intentionally_stopped.add(thread_id)
|
|
74
|
+
if os.name == "nt":
|
|
75
|
+
try:
|
|
76
|
+
target_proc.send_signal(signal.CTRL_BREAK_EVENT)
|
|
77
|
+
except Exception:
|
|
78
|
+
target_proc.kill()
|
|
79
|
+
else:
|
|
80
|
+
try:
|
|
81
|
+
os.killpg(os.getpgid(target_proc.pid), signal.SIGTERM)
|
|
82
|
+
except Exception:
|
|
83
|
+
target_proc.kill()
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def _get_latest_conversation_id() -> str:
|
|
88
|
+
try:
|
|
89
|
+
from pathlib import Path
|
|
90
|
+
|
|
91
|
+
history_dir = Path.home() / ".gemini/antigravity-cli/brain"
|
|
92
|
+
if not history_dir.exists():
|
|
93
|
+
return ""
|
|
94
|
+
dirs = [d for d in history_dir.iterdir() if d.is_dir()]
|
|
95
|
+
if not dirs:
|
|
96
|
+
return ""
|
|
97
|
+
latest = max(dirs, key=lambda x: x.stat().st_mtime)
|
|
98
|
+
return latest.name
|
|
99
|
+
except Exception:
|
|
100
|
+
return ""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def run_agy(
|
|
104
|
+
*args, timeout: int = 300, stream_queue: asyncio.Queue = None, thread_id: str = None, cwd: str = None
|
|
105
|
+
) -> str:
|
|
106
|
+
args_list = list(args)
|
|
107
|
+
|
|
108
|
+
if cwd:
|
|
109
|
+
expanded_cwd = os.path.expanduser(cwd)
|
|
110
|
+
os.makedirs(expanded_cwd, exist_ok=True)
|
|
111
|
+
cwd_param = expanded_cwd
|
|
112
|
+
args_list = ["--add-dir", expanded_cwd] + args_list
|
|
113
|
+
else:
|
|
114
|
+
cwd_param = None
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
max_retries = 3
|
|
118
|
+
for attempt in range(max_retries):
|
|
119
|
+
try:
|
|
120
|
+
env = os.environ.copy()
|
|
121
|
+
env["AGY_DISCORD_BOT"] = "1"
|
|
122
|
+
env["PYTHONUNBUFFERED"] = "1"
|
|
123
|
+
if thread_id:
|
|
124
|
+
env["DISCORD_THREAD_ID"] = thread_id
|
|
125
|
+
|
|
126
|
+
libstdbuf_path = _find_libstdbuf() # works around agy's output-truncation bug
|
|
127
|
+
if libstdbuf_path:
|
|
128
|
+
existing_preload = env.get("LD_PRELOAD", "")
|
|
129
|
+
env["LD_PRELOAD"] = (
|
|
130
|
+
f"{libstdbuf_path}:{existing_preload}" if existing_preload else libstdbuf_path
|
|
131
|
+
)
|
|
132
|
+
env["_STDBUF_O"] = str(_STDOUT_BUFFER_SIZE)
|
|
133
|
+
|
|
134
|
+
kwargs = {
|
|
135
|
+
"stdout": asyncio.subprocess.PIPE,
|
|
136
|
+
"stderr": asyncio.subprocess.PIPE,
|
|
137
|
+
"stdin": asyncio.subprocess.DEVNULL,
|
|
138
|
+
"cwd": cwd_param,
|
|
139
|
+
"env": env,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if os.name == "nt":
|
|
143
|
+
import subprocess
|
|
144
|
+
|
|
145
|
+
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
146
|
+
elif hasattr(os, "setsid"):
|
|
147
|
+
kwargs["preexec_fn"] = os.setsid
|
|
148
|
+
|
|
149
|
+
from config import AGY_BIN
|
|
150
|
+
|
|
151
|
+
cmd = [AGY_BIN] + args_list
|
|
152
|
+
|
|
153
|
+
async with agy_start_lock:
|
|
154
|
+
proc = await asyncio.create_subprocess_exec(*cmd, **kwargs)
|
|
155
|
+
if thread_id:
|
|
156
|
+
active_processes[thread_id] = proc
|
|
157
|
+
|
|
158
|
+
if stream_queue and "--conversation" not in args:
|
|
159
|
+
await asyncio.sleep(1.0)
|
|
160
|
+
latest_conv_id = await _get_latest_conversation_id()
|
|
161
|
+
if latest_conv_id:
|
|
162
|
+
await stream_queue.put(("__CONV_ID__:" + latest_conv_id, False))
|
|
163
|
+
|
|
164
|
+
stdout_chunks = []
|
|
165
|
+
stderr_chunks = []
|
|
166
|
+
|
|
167
|
+
import codecs
|
|
168
|
+
|
|
169
|
+
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
170
|
+
|
|
171
|
+
ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
172
|
+
|
|
173
|
+
async def read_stdout():
|
|
174
|
+
while True:
|
|
175
|
+
chunk = await proc.stdout.read(1024)
|
|
176
|
+
if not chunk:
|
|
177
|
+
decoded = decoder.decode(b"", final=True)
|
|
178
|
+
if decoded:
|
|
179
|
+
clean = ansi_escape.sub("", decoded)
|
|
180
|
+
stdout_chunks.append(clean)
|
|
181
|
+
if stream_queue is not None:
|
|
182
|
+
await stream_queue.put((clean, False))
|
|
183
|
+
break
|
|
184
|
+
decoded = decoder.decode(chunk, final=False)
|
|
185
|
+
if decoded:
|
|
186
|
+
clean = ansi_escape.sub("", decoded)
|
|
187
|
+
stdout_chunks.append(clean)
|
|
188
|
+
if stream_queue is not None:
|
|
189
|
+
await stream_queue.put((clean, False))
|
|
190
|
+
if (
|
|
191
|
+
"(Calls tool:" in clean
|
|
192
|
+
or "Tool Output:" in clean
|
|
193
|
+
or "Tool Execute:" in clean
|
|
194
|
+
or clean.startswith("● ")
|
|
195
|
+
):
|
|
196
|
+
await stream_queue.put(("__SPLIT__", True))
|
|
197
|
+
|
|
198
|
+
async def read_stderr():
|
|
199
|
+
while True:
|
|
200
|
+
chunk = await proc.stderr.read(1024)
|
|
201
|
+
if not chunk:
|
|
202
|
+
break
|
|
203
|
+
stderr_chunks.append(chunk)
|
|
204
|
+
|
|
205
|
+
async def _gather_pipes():
|
|
206
|
+
await asyncio.gather(read_stdout(), read_stderr())
|
|
207
|
+
|
|
208
|
+
gather_task = asyncio.create_task(_gather_pipes())
|
|
209
|
+
wait_task = asyncio.create_task(proc.wait())
|
|
210
|
+
|
|
211
|
+
# Slices let the timeout pause during a pending Discord approval (up to 3600s).
|
|
212
|
+
from config import session_manager as _sm
|
|
213
|
+
|
|
214
|
+
poll_slice = 5.0
|
|
215
|
+
remaining_budget = float(timeout)
|
|
216
|
+
timed_out = False
|
|
217
|
+
while True:
|
|
218
|
+
done, _pending_tasks = await asyncio.wait(
|
|
219
|
+
[gather_task, wait_task], return_when=asyncio.FIRST_COMPLETED, timeout=poll_slice
|
|
220
|
+
)
|
|
221
|
+
if done:
|
|
222
|
+
break
|
|
223
|
+
if not _sm.pending_approvals:
|
|
224
|
+
remaining_budget -= poll_slice
|
|
225
|
+
if remaining_budget <= 0:
|
|
226
|
+
timed_out = True
|
|
227
|
+
break
|
|
228
|
+
|
|
229
|
+
if timed_out:
|
|
230
|
+
gather_task.cancel()
|
|
231
|
+
raise asyncio.TimeoutError()
|
|
232
|
+
|
|
233
|
+
if wait_task in done:
|
|
234
|
+
try:
|
|
235
|
+
await asyncio.wait_for(gather_task, timeout=1.0)
|
|
236
|
+
except asyncio.TimeoutError:
|
|
237
|
+
gather_task.cancel()
|
|
238
|
+
elif gather_task in done:
|
|
239
|
+
try:
|
|
240
|
+
await asyncio.wait_for(wait_task, timeout=2.0)
|
|
241
|
+
except asyncio.TimeoutError:
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
text = "".join(stdout_chunks).strip()
|
|
245
|
+
err_text = b"".join(stderr_chunks).decode(errors="replace").strip()
|
|
246
|
+
if err_text:
|
|
247
|
+
logger.warning(f"[AGY STDERR] {err_text}")
|
|
248
|
+
|
|
249
|
+
if proc.returncode is not None and proc.returncode != 0:
|
|
250
|
+
if thread_id in _intentionally_stopped or proc.returncode in (-15, -9, 15, 9, 143, 137):
|
|
251
|
+
error_msg = "🛑 Generation stopped by user request."
|
|
252
|
+
if stream_queue is not None:
|
|
253
|
+
await stream_queue.put(("\n\n" + error_msg, True))
|
|
254
|
+
return error_msg
|
|
255
|
+
|
|
256
|
+
if "authentication failed or timed out" in text or "authentication failed or timed out" in err_text:
|
|
257
|
+
if attempt < max_retries - 1:
|
|
258
|
+
logger.warning("[AGY RETRY] Authentication timeout. Retrying...")
|
|
259
|
+
await asyncio.sleep(2.0)
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
error_msg = f"⚠️ Agent exited abnormally (exit code {proc.returncode})\n"
|
|
263
|
+
if err_text:
|
|
264
|
+
error_msg += f"```\n{err_text}\n```\n"
|
|
265
|
+
if text:
|
|
266
|
+
error_msg += f"Output:\n```\n{text}\n```\n"
|
|
267
|
+
logger.error(f"[AGY ERROR] {error_msg}")
|
|
268
|
+
if stream_queue is not None:
|
|
269
|
+
await stream_queue.put(("\n\n" + error_msg, True))
|
|
270
|
+
return error_msg
|
|
271
|
+
|
|
272
|
+
# DEBUG-only (LOG_LEVEL) raw stdout capture.
|
|
273
|
+
logger.debug(f"[AGY RAW STDOUT] {text!r}")
|
|
274
|
+
return text or "(Empty response)"
|
|
275
|
+
|
|
276
|
+
except asyncio.CancelledError:
|
|
277
|
+
try:
|
|
278
|
+
if proc:
|
|
279
|
+
if os.name == "nt":
|
|
280
|
+
proc.send_signal(signal.CTRL_BREAK_EVENT)
|
|
281
|
+
else:
|
|
282
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
|
283
|
+
except Exception:
|
|
284
|
+
pass
|
|
285
|
+
msg = "🛑 AI Task manually stopped by user."
|
|
286
|
+
if stream_queue is not None:
|
|
287
|
+
await stream_queue.put(("\n\n" + msg, True))
|
|
288
|
+
return msg
|
|
289
|
+
except asyncio.TimeoutError:
|
|
290
|
+
try:
|
|
291
|
+
if proc:
|
|
292
|
+
if os.name == "nt":
|
|
293
|
+
proc.send_signal(signal.CTRL_BREAK_EVENT)
|
|
294
|
+
else:
|
|
295
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
|
296
|
+
except Exception:
|
|
297
|
+
pass
|
|
298
|
+
if attempt < max_retries - 1:
|
|
299
|
+
logger.warning("[AGY RETRY] Global timeout. Retrying...")
|
|
300
|
+
await asyncio.sleep(2.0)
|
|
301
|
+
continue
|
|
302
|
+
msg = "🛑 AI Task timed out."
|
|
303
|
+
if stream_queue is not None:
|
|
304
|
+
await stream_queue.put(("\n\n" + msg, True))
|
|
305
|
+
return msg
|
|
306
|
+
except Exception as e:
|
|
307
|
+
logger.exception(f"[AGY UNEXPECTED ERROR] {e}")
|
|
308
|
+
if attempt < max_retries - 1:
|
|
309
|
+
logger.warning(f"[AGY RETRY] Unexpected error: {e}. Retrying...")
|
|
310
|
+
await asyncio.sleep(2.0)
|
|
311
|
+
continue
|
|
312
|
+
msg = f"🛑 AI Task encountered an error: {str(e)}"
|
|
313
|
+
if stream_queue is not None:
|
|
314
|
+
await stream_queue.put(("\n\n" + msg, True))
|
|
315
|
+
return msg
|
|
316
|
+
finally:
|
|
317
|
+
if thread_id and thread_id in active_processes:
|
|
318
|
+
del active_processes[thread_id]
|
|
319
|
+
_intentionally_stopped.discard(thread_id)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
async def agy_new_conversation(
|
|
323
|
+
content: str, model: str = None, stream_queue: asyncio.Queue = None, thread_id: str = None, cwd: str = None
|
|
324
|
+
) -> tuple[str, str]:
|
|
325
|
+
# --print consumes the next token as the prompt, so the flag must come first.
|
|
326
|
+
args = ["--dangerously-skip-permissions", "--print", content]
|
|
327
|
+
if model:
|
|
328
|
+
args.extend(["--model", model])
|
|
329
|
+
result_text = await run_agy(*args, stream_queue=stream_queue, thread_id=thread_id, cwd=cwd)
|
|
330
|
+
conv_id = await _get_latest_conversation_id()
|
|
331
|
+
return result_text, conv_id
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
async def agy_send_message(
|
|
335
|
+
conv_id: str,
|
|
336
|
+
content: str,
|
|
337
|
+
model: str = None,
|
|
338
|
+
stream_queue: asyncio.Queue = None,
|
|
339
|
+
thread_id: str = None,
|
|
340
|
+
cwd: str = None,
|
|
341
|
+
) -> str:
|
|
342
|
+
args = ["--dangerously-skip-permissions", "--print", content, "--conversation", conv_id]
|
|
343
|
+
if model:
|
|
344
|
+
args.extend(["--model", model])
|
|
345
|
+
return await run_agy(*args, stream_queue=stream_queue, thread_id=thread_id, cwd=cwd)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def get_current_model() -> str:
|
|
349
|
+
import json
|
|
350
|
+
from pathlib import Path
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
settings_path = Path.home() / ".gemini/antigravity-cli/settings.json"
|
|
354
|
+
if settings_path.exists():
|
|
355
|
+
data = json.loads(settings_path.read_text())
|
|
356
|
+
return data.get("model", "Default")
|
|
357
|
+
except Exception:
|
|
358
|
+
logger.warning("Failed to read current model from settings.json")
|
|
359
|
+
return "Default"
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
async def generate_thread_title(user_input: str, response: str) -> str:
|
|
363
|
+
fallback = user_input.replace("\n", " ").strip()
|
|
364
|
+
if len(fallback) > 50:
|
|
365
|
+
fallback = fallback[:47] + "..."
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
prompt = (
|
|
369
|
+
"Summarize the topic of this exchange in 5 words or fewer, as a short title. "
|
|
370
|
+
"Reply with ONLY the title text, no quotes, no punctuation at the end.\n\n"
|
|
371
|
+
f"Question: {user_input[:500]}\n\nAnswer: {response[:500]}"
|
|
372
|
+
)
|
|
373
|
+
title = await run_agy("--print", prompt, timeout=30)
|
|
374
|
+
title = title.strip().strip('"').strip("'")
|
|
375
|
+
if not title or len(title) > 80:
|
|
376
|
+
return fallback
|
|
377
|
+
return title
|
|
378
|
+
except Exception as e:
|
|
379
|
+
logger.warning(f"AI thread-title generation failed, falling back to raw input: {e}")
|
|
380
|
+
return fallback
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def atomic_write_json(path: Path, data) -> None:
|
|
7
|
+
"""Writes JSON atomically: writes to a temp file first, then renames it
|
|
8
|
+
into place. os.replace() is atomic on both POSIX and Windows, so a
|
|
9
|
+
crash mid-write leaves the ORIGINAL file untouched instead of a
|
|
10
|
+
half-written, corrupted one."""
|
|
11
|
+
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
|
12
|
+
tmp_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
13
|
+
os.replace(tmp_path, path)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def safe_load_json(path: Path, default, logger=None):
|
|
17
|
+
"""Loads JSON, returning `default` (and backing up the file) if it's
|
|
18
|
+
missing or fails to parse - and actually logging that, instead of
|
|
19
|
+
silently pretending nothing was ever saved."""
|
|
20
|
+
if not path.exists():
|
|
21
|
+
return default
|
|
22
|
+
try:
|
|
23
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
24
|
+
except Exception as e:
|
|
25
|
+
if logger:
|
|
26
|
+
logger.error(f"Failed to parse {path}: {e} - backing up as .corrupted and starting fresh")
|
|
27
|
+
try:
|
|
28
|
+
path.replace(path.with_suffix(path.suffix + ".corrupted"))
|
|
29
|
+
except OSError:
|
|
30
|
+
pass
|
|
31
|
+
return default
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from sys import stdout
|
|
5
|
+
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def init_logger(workspace_dir: Path):
|
|
10
|
+
logging.getLogger("discord").setLevel(logging.WARNING)
|
|
11
|
+
LOG_DIR = workspace_dir / "logs"
|
|
12
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
logger.remove()
|
|
14
|
+
log_format = "<green>{time:YYYY-MM-DD HH:mm:ss}</green> <level>{level: <5}</level> <cyan>{name}</cyan>: {message}"
|
|
15
|
+
file_format = "{time:YYYY-MM-DD HH:mm:ss} {level: <5} {name}: {message}"
|
|
16
|
+
# Defaults to INFO - set LOG_LEVEL=DEBUG then `lgy restart` for
|
|
17
|
+
# verbose detail (e.g. agy_runner.py's raw agy stdout capture).
|
|
18
|
+
level = os.environ.get("LOG_LEVEL", "INFO").upper()
|
|
19
|
+
logger.add(stdout, level=level, format=log_format, colorize=True)
|
|
20
|
+
logger.add(
|
|
21
|
+
LOG_DIR / "bot.log",
|
|
22
|
+
format=file_format,
|
|
23
|
+
level=level,
|
|
24
|
+
rotation="10 MB",
|
|
25
|
+
retention="7 days",
|
|
26
|
+
encoding="utf-8",
|
|
27
|
+
)
|
|
28
|
+
return logger
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from core.atomic_io import atomic_write_json, safe_load_json
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SessionManager:
|
|
9
|
+
"""Manages conversation state, async streaming queues, and user approval states."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, data_dir: Path):
|
|
12
|
+
self.session_file = data_dir / "sessions.json"
|
|
13
|
+
self.persistent_file = data_dir / "persistent_tools.json"
|
|
14
|
+
|
|
15
|
+
self.sessions: dict[str, dict] = self._load_sessions()
|
|
16
|
+
self.active_queues: dict[str, asyncio.Queue] = {}
|
|
17
|
+
self.active_tts_tasks: dict[str, asyncio.Task] = {}
|
|
18
|
+
self.pending_approvals: dict[str, asyncio.Future] = {}
|
|
19
|
+
self.pending_approval_types: dict[str, str] = {}
|
|
20
|
+
self.pending_approval_messages: dict[str, Any] = {}
|
|
21
|
+
# conv_id -> current approval_key. Keyed by approval_key (not conv_id)
|
|
22
|
+
# so a 2nd call can't overwrite the 1st's still-pending Future.
|
|
23
|
+
self.active_approval_by_conv: dict[str, str] = {}
|
|
24
|
+
|
|
25
|
+
self.persistent_allowed: dict = self._load_persistent()
|
|
26
|
+
self.session_allowed_tools: dict[str, set] = {}
|
|
27
|
+
|
|
28
|
+
def _load_sessions(self) -> dict:
|
|
29
|
+
from config import logger
|
|
30
|
+
|
|
31
|
+
return safe_load_json(self.session_file, {}, logger=logger)
|
|
32
|
+
|
|
33
|
+
def save_sessions(self):
|
|
34
|
+
atomic_write_json(self.session_file, self.sessions)
|
|
35
|
+
|
|
36
|
+
def get_session(self, thread_id: str) -> dict | None:
|
|
37
|
+
return self.sessions.get(str(thread_id))
|
|
38
|
+
|
|
39
|
+
def set_session(self, thread_id: str, data: dict):
|
|
40
|
+
self.sessions[str(thread_id)] = data
|
|
41
|
+
self.save_sessions()
|
|
42
|
+
|
|
43
|
+
def update_session(self, thread_id: str, key: str, value: Any):
|
|
44
|
+
if str(thread_id) in self.sessions:
|
|
45
|
+
self.sessions[str(thread_id)][key] = value
|
|
46
|
+
self.save_sessions()
|
|
47
|
+
|
|
48
|
+
def remove_session(self, thread_id: str) -> dict | None:
|
|
49
|
+
sess = self.sessions.pop(str(thread_id), None)
|
|
50
|
+
self.save_sessions()
|
|
51
|
+
return sess
|
|
52
|
+
|
|
53
|
+
def get_all_sessions(self) -> dict[str, dict]:
|
|
54
|
+
return self.sessions
|
|
55
|
+
|
|
56
|
+
def _load_persistent(self) -> dict:
|
|
57
|
+
from config import logger
|
|
58
|
+
|
|
59
|
+
data = safe_load_json(self.persistent_file, {"tools": [], "commands": []}, logger=logger)
|
|
60
|
+
if isinstance(data, list):
|
|
61
|
+
return {"tools": data, "commands": []}
|
|
62
|
+
return data
|
|
63
|
+
|
|
64
|
+
def save_persistent(self):
|
|
65
|
+
atomic_write_json(self.persistent_file, self.persistent_allowed)
|
|
66
|
+
|
|
67
|
+
def register_queue(self, thread_id: str, queue: asyncio.Queue):
|
|
68
|
+
self.active_queues[str(thread_id)] = queue
|
|
69
|
+
|
|
70
|
+
def remove_queue(self, thread_id: str) -> asyncio.Queue | None:
|
|
71
|
+
return self.active_queues.pop(str(thread_id), None)
|
|
72
|
+
|
|
73
|
+
def get_queue(self, thread_id: str) -> asyncio.Queue | None:
|
|
74
|
+
return self.active_queues.get(str(thread_id))
|
|
75
|
+
|
|
76
|
+
def has_active_queues(self) -> bool:
|
|
77
|
+
return bool(self.active_queues)
|
|
78
|
+
|
|
79
|
+
def get_active_queue_keys(self) -> list:
|
|
80
|
+
return list(self.active_queues.keys())
|
|
81
|
+
|
|
82
|
+
def get_tts_task(self, thread_id: str) -> asyncio.Task | None:
|
|
83
|
+
return self.active_tts_tasks.get(str(thread_id))
|
|
84
|
+
|
|
85
|
+
def set_tts_task(self, thread_id: str, task: asyncio.Task):
|
|
86
|
+
self.active_tts_tasks[str(thread_id)] = task
|
|
87
|
+
|
|
88
|
+
def remove_tts_task(self, thread_id: str):
|
|
89
|
+
self.active_tts_tasks.pop(str(thread_id), None)
|
|
90
|
+
|
|
91
|
+
def set_pending_approval(
|
|
92
|
+
self, approval_key: str, future: asyncio.Future, app_type: str = "tool", conv_id: str | None = None
|
|
93
|
+
):
|
|
94
|
+
self.pending_approvals[approval_key] = future
|
|
95
|
+
self.pending_approval_types[approval_key] = app_type
|
|
96
|
+
if conv_id:
|
|
97
|
+
self.active_approval_by_conv[conv_id] = approval_key
|
|
98
|
+
|
|
99
|
+
def get_pending_approval(self, approval_key: str) -> asyncio.Future | None:
|
|
100
|
+
return self.pending_approvals.get(approval_key)
|
|
101
|
+
|
|
102
|
+
def get_pending_approval_by_conv(self, conv_id: str) -> asyncio.Future | None:
|
|
103
|
+
"""Looks up whichever approval is CURRENTLY active for a given
|
|
104
|
+
conversation - for callers that only have the stable
|
|
105
|
+
conversation_id, not the specific per-call approval_key (voice/
|
|
106
|
+
text "yes"/"no" responses, /stop)."""
|
|
107
|
+
approval_key = self.active_approval_by_conv.get(conv_id)
|
|
108
|
+
if not approval_key:
|
|
109
|
+
return None
|
|
110
|
+
return self.pending_approvals.get(approval_key)
|
|
111
|
+
|
|
112
|
+
def get_pending_approval_type_by_conv(self, conv_id: str) -> str:
|
|
113
|
+
approval_key = self.active_approval_by_conv.get(conv_id)
|
|
114
|
+
if not approval_key:
|
|
115
|
+
return "tool"
|
|
116
|
+
return self.pending_approval_types.get(approval_key, "tool")
|
|
117
|
+
|
|
118
|
+
def clear_pending_approval(self, approval_key: str):
|
|
119
|
+
self.pending_approvals.pop(approval_key, None)
|
|
120
|
+
self.pending_approval_types.pop(approval_key, None)
|
|
121
|
+
self.pending_approval_messages.pop(approval_key, None)
|
|
122
|
+
# Only remove the conv_id pointer if it still points at THIS key -
|
|
123
|
+
# a newer call may have already overwritten it.
|
|
124
|
+
for conv, key in list(self.active_approval_by_conv.items()):
|
|
125
|
+
if key == approval_key:
|
|
126
|
+
del self.active_approval_by_conv[conv]
|