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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -0
  3. package/bin/cli.js +278 -0
  4. package/bin/setup.js +260 -0
  5. package/hooks/hook.py +60 -0
  6. package/hooks/stop_hook.py +64 -0
  7. package/npm-scripts/postinstall.js +62 -0
  8. package/npm-scripts/prepare.js +45 -0
  9. package/npm-scripts/register-hook.js +182 -0
  10. package/npm-scripts/run-dev.js +9 -0
  11. package/npm-scripts/venv-paths.js +45 -0
  12. package/package.json +59 -0
  13. package/requirements.txt +13 -0
  14. package/src/api/server.py +48 -0
  15. package/src/api/ui_routes.py +340 -0
  16. package/src/api/voice_routes.py +94 -0
  17. package/src/approval/command_parser.py +62 -0
  18. package/src/approval/tool_formatter.py +68 -0
  19. package/src/cogs/general_cog.py +287 -0
  20. package/src/cogs/voice/__init__.py +0 -0
  21. package/src/cogs/voice/enrollment.py +436 -0
  22. package/src/cogs/voice/stt_session.py +121 -0
  23. package/src/cogs/voice_cog.py +573 -0
  24. package/src/config.py +123 -0
  25. package/src/core/agy_runner.py +380 -0
  26. package/src/core/atomic_io.py +31 -0
  27. package/src/core/logger.py +28 -0
  28. package/src/core/session_manager.py +126 -0
  29. package/src/handlers/message_router.py +16 -0
  30. package/src/handlers/thread_reply.py +165 -0
  31. package/src/main.py +313 -0
  32. package/src/messengers/base.py +105 -0
  33. package/src/messengers/discord_adapter.py +240 -0
  34. package/src/messengers/registry.py +19 -0
  35. package/src/services/audio_service.py +67 -0
  36. package/src/services/discord_helpers.py +95 -0
  37. package/src/services/discord_mcp.py +50 -0
  38. package/src/services/response.py +51 -0
  39. package/src/services/streaming.py +199 -0
  40. package/src/utils/utils.py +40 -0
  41. package/voice-service/index.js +1048 -0
  42. package/voice-service/package-lock.json +1880 -0
  43. 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]