linkgravity 1.2.2 → 1.4.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.
@@ -7,6 +7,7 @@ from discord import app_commands
7
7
  from discord.ext import commands, tasks
8
8
 
9
9
  from config import allowed, logger
10
+ from messengers.registry import get_adapter_for_platform
10
11
 
11
12
  from .voice.enrollment import EnrollmentManager
12
13
  from .voice.stt_session import SttSessionTracker
@@ -175,22 +176,19 @@ class VoiceCog(commands.Cog):
175
176
  guild_id = interaction.guild_id
176
177
 
177
178
  wake_word_map = self.bot_settings.get("wake_words") or {}
178
- has_wake_word = bool(wake_word_map)
179
+ own_word = wake_word_map.get(str(interaction.user.id))
179
180
  active_timer = self.bot_settings.get("active_timer", 60)
180
181
 
181
- if has_wake_word:
182
- # dict.fromkeys dedupes while keeping first-registered order (each user has their own word).
183
- ww_list = [f"`{w.strip()}`" for w in dict.fromkeys(wake_word_map.values()) if w.strip()]
184
- ww_str = ", ".join(ww_list[:-1]) + f", or {ww_list[-1]}" if len(ww_list) > 1 else ww_list[0]
182
+ if own_word:
185
183
  msg = (
186
184
  f"🎤 Connected to `{vc_chan.name}`.\n"
187
- f"💡 Say {ww_str} to activate me. Once awake, I'll keep listening for {active_timer} seconds after each interaction.\n"
185
+ f"💡 Say `{own_word}` to activate me. Once awake, I'll keep listening for {active_timer} seconds after each interaction.\n"
188
186
  f"⚙️ You can customize settings using `/sound`."
189
187
  )
190
188
  else:
191
189
  msg = (
192
190
  f"🎤 Connected to `{vc_chan.name}`.\n"
193
- f"🎙️ No wake word is set up yet, so I can't hear you yet - run `/sound wake_word:<word>` "
191
+ f"🎙️ You haven't set up a wake word yet, so I can't hear you - run `/sound wake_word:<word>` "
194
192
  f"and say your chosen word a few times to register it in your voice."
195
193
  )
196
194
  await interaction.response.send_message(msg)
@@ -211,17 +209,17 @@ class VoiceCog(commands.Cog):
211
209
  if data.get("success"):
212
210
  self._voice_state[str(guild_id)] = interaction.channel_id
213
211
 
214
- if has_wake_word:
212
+ if own_word:
215
213
  if self.bot_settings.get("tts_enabled", True):
216
- welcome_audio = await self.tts("Yes, I am listening.")
214
+ welcome_audio = await self.tts("Voice connected.")
217
215
  if welcome_audio:
218
- await self._play_audio(str(guild_id), welcome_audio)
216
+ await self._play_audio(str(guild_id), welcome_audio, suppress_active_window=True)
219
217
  elif self.bot_settings.get("tts_enabled", True):
220
218
  prompt_audio = await self.tts(
221
219
  "No wake word is set up yet. Please use the sound command to set one."
222
220
  )
223
221
  if prompt_audio:
224
- await self._play_audio(str(guild_id), prompt_audio)
222
+ await self._play_audio(str(guild_id), prompt_audio, suppress_active_window=True)
225
223
  else:
226
224
  await interaction.channel.send(f"⚠️ Node.js integration failed: {data.get('error')}")
227
225
  except aiohttp.ClientConnectorError:
@@ -452,9 +450,18 @@ class VoiceCog(commands.Cog):
452
450
 
453
451
  self.logger.debug(f"STT recognized: {text} -> AI: {text_to_ai}")
454
452
 
455
- # A stale in-flight turn for this guild - cancel it, kill its agy process, stop playback.
453
+ sess = self.session_manager.get_session(str(thread_id))
454
+ if not sess:
455
+ sess = {"status": "pending", "user_id": str(user_id)}
456
+ self.session_manager.set_session(str(thread_id), sess)
457
+
458
+ conv_id = sess.get("conversation_id")
459
+ pa = self.session_manager.get_pending_approval_by_conv(conv_id) if conv_id else None
460
+ has_pending_approval = bool(conv_id and pa and not pa.done())
461
+
462
+ # Cancel a stale in-flight turn, unless a tool/question approval is pending (this utterance may be its answer).
456
463
  prev_task = self._active_turns.get(str(guild_id))
457
- if prev_task and not prev_task.done():
464
+ if prev_task and not prev_task.done() and not has_pending_approval:
458
465
  prev_task.cancel()
459
466
 
460
467
  from core.agy_runner import stop_active_process
@@ -493,15 +500,7 @@ class VoiceCog(commands.Cog):
493
500
  await self._play_audio(str(guild_id), audio_reply)
494
501
  return
495
502
 
496
- sess = self.session_manager.get_session(str(thread_id))
497
- if not sess:
498
- sess = {"status": "pending", "user_id": str(user_id)}
499
- self.session_manager.set_session(str(thread_id), sess)
500
-
501
- conv_id = sess.get("conversation_id")
502
- pa = self.session_manager.get_pending_approval_by_conv(conv_id) if conv_id else None
503
-
504
- if conv_id and pa and not pa.done():
503
+ if has_pending_approval:
505
504
  app_type = self.session_manager.get_pending_approval_type_by_conv(conv_id)
506
505
  if app_type == "ask_question":
507
506
  pa.set_result(text)
@@ -530,7 +529,7 @@ class VoiceCog(commands.Cog):
530
529
  self.session_manager.register_queue(str(thread.id), queue)
531
530
 
532
531
  ctx = {"status_msg": None}
533
- consume_task = asyncio.create_task(self.stream_thinking_latest(thread, ctx, queue))
532
+ consume_task = asyncio.create_task(self.stream_thinking_latest(thread, str(thread.id), ctx, queue))
534
533
 
535
534
  try:
536
535
  if is_new_session:
@@ -545,6 +544,12 @@ class VoiceCog(commands.Cog):
545
544
  sess["status"] = "active"
546
545
  self.session_manager.set_session(str(thread_id), sess)
547
546
  conv_id = new_conv_id
547
+
548
+ from utils.utils import generate_thread_title, update_agy_conversation_title
549
+
550
+ new_title = await generate_thread_title(text_to_ai, raw_ans)
551
+ await get_adapter_for_platform("discord").rename_conversation(thread, new_title)
552
+ await update_agy_conversation_title(new_conv_id, new_title)
548
553
  else:
549
554
  logger.debug("Voice: calling agy_send...")
550
555
  raw_ans = await self.agy_send(
package/src/config.py CHANGED
@@ -7,8 +7,7 @@ WORKSPACE_DIR = Path.home() / ".gemini" / "linkgravity"
7
7
  WORKSPACE_DIR.mkdir(parents=True, exist_ok=True)
8
8
  DATA_DIR = WORKSPACE_DIR / "data"
9
9
  DATA_DIR.mkdir(parents=True, exist_ok=True)
10
- # Per-user wake-word recordings + built .rpw reference (see EnrollmentManager).
11
- # Defined early so load_bot_settings' migration below can read it.
10
+ # Per-user wake-word recordings (see EnrollmentManager); defined early so load_bot_settings' migration below can read it.
12
11
  WAKE_REF_DIR = WORKSPACE_DIR / "wake_refs"
13
12
  WAKE_REF_DIR.mkdir(parents=True, exist_ok=True)
14
13
 
@@ -16,6 +15,8 @@ LGY_CONFIG_FILE = WORKSPACE_DIR / "lgy.json"
16
15
 
17
16
  DEFAULT_LGY_CONFIG = {
18
17
  "discord_token": "",
18
+ "telegram_token": "",
19
+ "telegram_allowed_user_ids": "",
19
20
  "session_scopes": [],
20
21
  "allowed_user_ids": "",
21
22
  # user_id (str) -> registered word, one per person (see EnrollmentManager._commit_enrollment).
@@ -74,6 +75,7 @@ logger = init_logger(WORKSPACE_DIR)
74
75
 
75
76
 
76
77
  DISCORD_TOKEN = bot_settings.get("discord_token", "")
78
+ TELEGRAM_TOKEN = bot_settings.get("telegram_token", "")
77
79
 
78
80
 
79
81
  def _parse_session_scopes(raw_scopes) -> dict:
@@ -97,6 +99,7 @@ def _parse_session_scopes(raw_scopes) -> dict:
97
99
 
98
100
  SESSION_SCOPES = _parse_session_scopes(bot_settings.get("session_scopes"))
99
101
  ALLOWED_IDS = set(int(x) for x in bot_settings.get("allowed_user_ids", "").split(",") if x.strip())
102
+ TELEGRAM_ALLOWED_IDS = set(int(x) for x in bot_settings.get("telegram_allowed_user_ids", "").split(",") if x.strip())
100
103
  TTS_VOICE = bot_settings.get("tts_voice", "ko-KR-SunHiNeural")
101
104
 
102
105
 
@@ -120,6 +123,7 @@ TMP_VOICE_DIR.mkdir(parents=True, exist_ok=True)
120
123
 
121
124
  MAX_EMBED_LEN = 1900
122
125
  STREAM_RATE_LIMIT_SEC = 0.5
126
+ APPROVAL_TIMEOUT_SEC = 1800
123
127
  PERSISTENT_FILE = DATA_DIR / "persistent_tools.json"
124
128
  SESSION_FILE = DATA_DIR / "sessions.json"
125
129
 
@@ -136,5 +140,6 @@ AGY_BIN = os.getenv("AGY_BIN_PATH", str(Path.home() / ".local/bin/agy"))
136
140
  session_manager = SessionManager(DATA_DIR)
137
141
 
138
142
 
139
- def allowed(user_id: int) -> bool:
140
- return not ALLOWED_IDS or user_id in ALLOWED_IDS
143
+ def allowed(user_id: int, platform: str = "discord") -> bool:
144
+ ids = TELEGRAM_ALLOWED_IDS if platform == "telegram" else ALLOWED_IDS
145
+ return not ids or user_id in ids
@@ -118,10 +118,10 @@ async def run_agy(
118
118
  for attempt in range(max_retries):
119
119
  try:
120
120
  env = os.environ.copy()
121
- env["AGY_DISCORD_BOT"] = "1"
121
+ env["LGY_APPROVAL_HOOK"] = "1"
122
122
  env["PYTHONUNBUFFERED"] = "1"
123
123
  if thread_id:
124
- env["DISCORD_THREAD_ID"] = thread_id
124
+ env["LGY_THREAD_ID"] = thread_id
125
125
 
126
126
  libstdbuf_path = _find_libstdbuf() # works around agy's output-truncation bug
127
127
  if libstdbuf_path:
@@ -206,7 +206,7 @@ async def run_agy(
206
206
  gather_task = asyncio.create_task(_gather_pipes())
207
207
  wait_task = asyncio.create_task(proc.wait())
208
208
 
209
- # Slices let the timeout pause during a pending Discord approval (up to 3600s).
209
+ # Slices let the timeout pause during a pending tool approval (up to 3600s).
210
210
  from config import session_manager as _sm
211
211
 
212
212
  poll_slice = 5.0
@@ -376,3 +376,36 @@ async def generate_thread_title(user_input: str, response: str) -> str:
376
376
  except Exception as e:
377
377
  logger.warning(f"AI thread-title generation failed, falling back to raw input: {e}")
378
378
  return fallback
379
+
380
+
381
+ async def update_agy_conversation_title(conv_id: str, title: str) -> None:
382
+ """Antigravity CLI's own conversation list reads its display name from
383
+ conversation_summaries.db's `preview` column (the `title` column exists
384
+ but is unused/always empty - confirmed by inspecting the db directly).
385
+ Without this, agy shows its own auto-generated name while the Discord
386
+ thread shows ours, and the two drift apart for the same conversation."""
387
+ if not conv_id:
388
+ return
389
+
390
+ import sqlite3
391
+ from pathlib import Path
392
+
393
+ db_path = Path.home() / ".gemini/antigravity-cli/conversation_summaries.db"
394
+ if not db_path.exists():
395
+ return
396
+
397
+ def _update():
398
+ conn = sqlite3.connect(str(db_path), timeout=5)
399
+ try:
400
+ conn.execute(
401
+ "UPDATE conversation_summaries SET preview = ? WHERE conversation_id = ?",
402
+ (title, conv_id),
403
+ )
404
+ conn.commit()
405
+ finally:
406
+ conn.close()
407
+
408
+ try:
409
+ await asyncio.get_running_loop().run_in_executor(None, _update)
410
+ except Exception as e:
411
+ logger.warning(f"Failed to sync title into agy's conversation_summaries.db for {conv_id}: {e}")
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ from datetime import datetime
2
3
  from pathlib import Path
3
4
  from typing import Any
4
5
 
@@ -79,6 +80,30 @@ class SessionManager:
79
80
  def get_active_queue_keys(self) -> list:
80
81
  return list(self.active_queues.keys())
81
82
 
83
+ def cleanup_stale_sessions(self, pending_max_age_days: int = 7) -> int:
84
+ """Only ever removes 'pending' sessions (started with /new but never actually used - no real conversation attached) older than pending_max_age_days, or with no created_at at all (pre-dates that field, safe to treat as stale). 'active' sessions are NEVER removed by age: Discord threads must stay resumable indefinitely, and Telegram's 1-chat-1-session model already overwrites its one entry on each /new, so there's nothing to accumulate there either. Returns how many were removed."""
85
+ now = datetime.now()
86
+ to_remove = []
87
+ for thread_id, session in self.sessions.items():
88
+ if session.get("status") != "pending":
89
+ continue
90
+ created_at_str = session.get("created_at")
91
+ if not created_at_str:
92
+ to_remove.append(thread_id)
93
+ continue
94
+ try:
95
+ age_days = (now - datetime.fromisoformat(created_at_str)).days
96
+ except ValueError:
97
+ continue
98
+ if age_days > pending_max_age_days:
99
+ to_remove.append(thread_id)
100
+
101
+ for thread_id in to_remove:
102
+ self.sessions.pop(thread_id, None)
103
+ if to_remove:
104
+ self.save_sessions()
105
+ return len(to_remove)
106
+
82
107
  def get_tts_task(self, thread_id: str) -> asyncio.Task | None:
83
108
  return self.active_tts_tasks.get(str(thread_id))
84
109
 
@@ -1,16 +1,13 @@
1
- import discord
2
-
3
1
  from config import allowed
4
2
  from handlers.thread_reply import handle_thread_reply
3
+ from messengers.base import MessengerAdapter
5
4
 
6
5
 
7
- async def handle_message(bot, message: discord.Message):
8
- if message.type not in (discord.MessageType.default, discord.MessageType.reply):
9
- return
10
- if message.author.bot:
6
+ async def handle_message(bot, raw_event, adapter: MessengerAdapter):
7
+ incoming = adapter.to_incoming_message(raw_event)
8
+ if incoming is None:
11
9
  return
12
- if not allowed(message.author.id):
10
+ if not allowed(incoming.author_id, incoming.platform):
13
11
  return
14
12
 
15
- if isinstance(message.channel, discord.Thread):
16
- await handle_thread_reply(bot, message)
13
+ await handle_thread_reply(bot, incoming)
@@ -2,10 +2,9 @@ import asyncio
2
2
  import time
3
3
  from datetime import datetime
4
4
 
5
- import discord
6
-
7
5
  from config import session_manager
8
- from messengers.registry import get_adapter
6
+ from messengers.base import IncomingMessage
7
+ from messengers.registry import get_adapter_for_platform
9
8
  from services.response import render_thought_process, send_agy_response
10
9
  from services.streaming import stream_thinking_latest
11
10
  from utils.utils import (
@@ -16,13 +15,19 @@ from utils.utils import (
16
15
  generate_thread_title,
17
16
  handle_image_attachments,
18
17
  stt,
18
+ update_agy_conversation_title,
19
19
  )
20
20
 
21
21
 
22
- async def handle_approval_reply(
23
- message: discord.Message, thread: discord.Thread, session: dict, content: str, pa
24
- ) -> bool:
25
- adapter = get_adapter()
22
+ async def handle_approval_reply(incoming: IncomingMessage, session: dict, content: str, pa) -> bool:
23
+ adapter = get_adapter_for_platform(incoming.platform)
24
+ thread = incoming.conversation_ref
25
+
26
+ if session_manager.get_pending_approval_type_by_conv(incoming.conversation_id) == "ask_question":
27
+ pa.set_result(content)
28
+ await adapter.send_message(thread, f'✅ *Answer Received (Write in): "{content}"*')
29
+ return True
30
+
26
31
  if content.lower() in ("yes", "y", "allow", "승인"):
27
32
  pa.set_result("allow")
28
33
  await adapter.send_message(thread, f'✅ *Answer Received (Write in): "{content}"*')
@@ -37,37 +42,42 @@ async def handle_approval_reply(
37
42
  return True
38
43
  elif content.lower() in ("clear", "reset"):
39
44
  await adapter.send_message(thread, "🧹 Conversation context cleared.")
40
- old_sess = session_manager.remove_session(str(thread.id))
45
+ old_sess = session_manager.remove_session(incoming.conversation_id)
41
46
  if old_sess:
42
- session_manager.set_session(str(thread.id), old_sess)
47
+ session_manager.set_session(incoming.conversation_id, old_sess)
43
48
  return True
44
49
  return False
45
50
 
46
51
 
47
52
  async def handle_pending_session(
48
- bot, thread: discord.Thread, session: dict, agy_content: str, content: str, image_paths: list
53
+ bot, incoming: IncomingMessage, session: dict, agy_content: str, content: str, image_paths: list
49
54
  ):
50
- adapter = get_adapter()
55
+ adapter = get_adapter_for_platform(incoming.platform)
56
+ thread = incoming.conversation_ref
51
57
  try:
52
58
  async with adapter.typing(thread):
53
59
  ctx = {"status_msg": None}
54
60
  start_time = time.time()
55
61
  queue = asyncio.Queue()
56
- session_manager.register_queue(str(thread.id), queue)
57
- stream_task = asyncio.create_task(stream_thinking_latest(bot, thread, context_dict=ctx, queue=queue))
62
+ session_manager.register_queue(incoming.conversation_id, queue)
63
+ stream_task = asyncio.create_task(
64
+ stream_thinking_latest(bot, thread, incoming.conversation_id, context_dict=ctx, queue=queue)
65
+ )
58
66
 
59
67
  cwd = session.get("cwd")
60
68
  model = session.get("model")
61
69
  result_text, new_conv_id = await agy_new_conversation(
62
- agy_content, model=model, stream_queue=queue, thread_id=str(thread.id), cwd=cwd
70
+ agy_content, model=model, stream_queue=queue, thread_id=incoming.conversation_id, cwd=cwd
63
71
  )
64
72
 
65
73
  await queue.put(("__END__", True))
66
74
  await stream_task
67
75
 
68
76
  response_text = result_text
69
- new_title = await generate_thread_title(content, response_text)
70
- await adapter.rename_conversation(thread, new_title)
77
+ if adapter.supports_renaming:
78
+ new_title = await generate_thread_title(content, response_text)
79
+ await adapter.rename_conversation(thread, new_title)
80
+ await update_agy_conversation_title(new_conv_id, new_title)
71
81
 
72
82
  response_text = await render_thought_process(new_conv_id, ctx, response_text, thread)
73
83
 
@@ -82,22 +92,25 @@ async def handle_pending_session(
82
92
 
83
93
 
84
94
  async def handle_existing_session(
85
- bot, thread: discord.Thread, session: dict, conv_id: str, agy_content: str, image_paths: list
95
+ bot, incoming: IncomingMessage, session: dict, conv_id: str, agy_content: str, image_paths: list
86
96
  ):
87
- adapter = get_adapter()
97
+ adapter = get_adapter_for_platform(incoming.platform)
98
+ thread = incoming.conversation_ref
88
99
  try:
89
100
  async with adapter.typing(thread):
90
101
  ctx = {"status_msg": None}
91
102
  start_time = time.time()
92
103
  queue = asyncio.Queue()
93
- session_manager.register_queue(str(thread.id), queue)
94
- stream_task = asyncio.create_task(stream_thinking_latest(bot, thread, context_dict=ctx, queue=queue))
104
+ session_manager.register_queue(incoming.conversation_id, queue)
105
+ stream_task = asyncio.create_task(
106
+ stream_thinking_latest(bot, thread, incoming.conversation_id, context_dict=ctx, queue=queue)
107
+ )
95
108
  result_text = await agy_send_message(
96
109
  conv_id,
97
110
  agy_content,
98
111
  model=session.get("model"),
99
112
  stream_queue=queue,
100
- thread_id=str(thread.id),
113
+ thread_id=incoming.conversation_id,
101
114
  cwd=session.get("cwd"),
102
115
  )
103
116
  await queue.put(("__END__", True))
@@ -110,14 +123,14 @@ async def handle_existing_session(
110
123
  cleanup_images(image_paths)
111
124
 
112
125
 
113
- async def handle_thread_reply(bot, message: discord.Message):
114
- thread = message.channel
115
- session = session_manager.get_session(str(thread.id))
126
+ async def handle_thread_reply(bot, incoming: IncomingMessage):
127
+ session = session_manager.get_session(incoming.conversation_id)
116
128
  if not session:
117
129
  return
118
130
 
119
- adapter = get_adapter()
120
- content = message.content.strip()
131
+ adapter = get_adapter_for_platform(incoming.platform)
132
+ thread = incoming.conversation_ref
133
+ content = incoming.content.strip()
121
134
  if content.startswith("/new"):
122
135
  await adapter.send_message(
123
136
  thread,
@@ -125,10 +138,11 @@ async def handle_thread_reply(bot, message: discord.Message):
125
138
  )
126
139
  return
127
140
 
128
- for att in message.attachments:
141
+ for att in incoming.attachments:
129
142
  ct = att.content_type or ""
130
143
  if "audio" in ct or att.filename.endswith((".ogg", ".mp3", ".m4a", ".wav")):
131
- await message.add_reaction("🎤")
144
+ if incoming.add_reaction:
145
+ await incoming.add_reaction("🎤")
132
146
  audio_bytes = await att.read()
133
147
  text = await stt(audio_bytes)
134
148
  if text:
@@ -136,9 +150,10 @@ async def handle_thread_reply(bot, message: discord.Message):
136
150
  await adapter.send_message(thread, f'🎤 *Speech Recognized: "{text}"*')
137
151
  break
138
152
 
139
- image_paths = await handle_image_attachments(message)
153
+ image_paths = await handle_image_attachments(incoming.attachments)
140
154
  if image_paths:
141
- await message.add_reaction("📎")
155
+ if incoming.add_reaction:
156
+ await incoming.add_reaction("📎")
142
157
  await adapter.send_message(thread, f"📎 *{len(image_paths)} file(s) attached*")
143
158
 
144
159
  if not content and not image_paths:
@@ -151,16 +166,31 @@ async def handle_thread_reply(bot, message: discord.Message):
151
166
  pa = session_manager.get_pending_approval_by_conv(conv_id) if conv_id else None
152
167
 
153
168
  if conv_id and pa and not pa.done():
154
- handled = await handle_approval_reply(message, thread, session, content, pa)
169
+ handled = await handle_approval_reply(incoming, session, content, pa)
155
170
  if handled:
156
171
  return
157
172
 
173
+ has_pending_approval = bool(conv_id and pa and not pa.done())
174
+ if session_manager.get_queue(incoming.conversation_id) is not None and not has_pending_approval:
175
+ from core.agy_runner import stop_active_process
176
+
177
+ stop_active_process(incoming.conversation_id)
178
+ session_manager.remove_queue(incoming.conversation_id)
179
+
180
+ prev_session = session_manager.get_session(incoming.conversation_id)
181
+ if prev_session:
182
+ session_manager.set_session(
183
+ incoming.conversation_id, {**prev_session, "status": "pending", "conversation_id": None}
184
+ )
185
+ session = session_manager.get_session(incoming.conversation_id)
186
+ conv_id = None
187
+
158
188
  if not conv_id:
159
189
  if session.get("status") == "pending":
160
- await handle_pending_session(bot, thread, session, agy_content, content, image_paths)
190
+ await handle_pending_session(bot, incoming, session, agy_content, content, image_paths)
161
191
  return
162
192
  else:
163
193
  await adapter.send_message(thread, "⚠️ Session ID not found. Start a new session with `/new`.")
164
194
  return
165
195
 
166
- await handle_existing_session(bot, thread, session, conv_id, agy_content, image_paths)
196
+ await handle_existing_session(bot, incoming, session, conv_id, agy_content, image_paths)