linkgravity 1.5.11 → 1.5.13

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/bin/cli.js CHANGED
@@ -45,8 +45,14 @@ function runPm2(args, silent = true) {
45
45
  const result = spawnSync(process.execPath, [PM2_BIN, ...args], {
46
46
  stdio: stdioOpt,
47
47
  cwd: path.join(__dirname, '..'),
48
- // pm2 gives Python a pipe not a TTY, so it block-buffers stdout and can sit on log lines indefinitely - force line buffering.
49
- env: { ...process.env, PYTHONUNBUFFERED: '1' },
48
+ env: {
49
+ ...process.env,
50
+ // pm2 gives Python a pipe not a TTY, so it block-buffers stdout and can sit on log lines indefinitely - force line buffering.
51
+ PYTHONUNBUFFERED: '1',
52
+ // Version managers (fnm, nvm) put node on PATH from a shell hook the daemon never runs,
53
+ // so the bot's own `node` lookup for voice-service would fail without this.
54
+ PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH || ''}`,
55
+ },
50
56
  });
51
57
 
52
58
  if (result.error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkgravity",
3
- "version": "1.5.11",
3
+ "version": "1.5.13",
4
4
  "description": "Discord/Telegram bot bridge for the Antigravity (agy) CLI, with voice interaction support",
5
5
  "scripts": {
6
6
  "start": "node npm-scripts/run-dev.js",
@@ -6,18 +6,13 @@ import uuid
6
6
 
7
7
  from aiohttp import web
8
8
 
9
+ from api.server import is_tool_allowed
9
10
  from config import APPROVAL_TIMEOUT_SEC, MAX_EMBED_LEN, logger, session_manager
10
11
  from messengers.base import ScopeOption
11
12
  from messengers.registry import get_adapter_for_platform, get_adapter_for_thread
12
13
  from utils.utils import split_message
13
14
 
14
15
 
15
- def is_tool_allowed(tool_name, tool_input):
16
- from api.server import is_tool_allowed as is_tool_allowed_orig
17
-
18
- return is_tool_allowed_orig(tool_name, tool_input)
19
-
20
-
21
16
  async def send_ordered(target_thread_id, send_coro_factory):
22
17
  """Routes through the same per-conversation stream queue as the answer
23
18
  text, so tool-call messages can't arrive out of order. Falls back to
@@ -57,9 +57,6 @@ def format_tool_display(tool_name: str, tool_input: dict) -> tuple[str, str, dic
57
57
 
58
58
 
59
59
  def format_bash_display(sub_cmd: str) -> tuple[str, str, dict]:
60
- """
61
- Formats a single bash sub-command for display.
62
- """
63
60
  is_long = "\n" in sub_cmd or len(sub_cmd) > 50
64
61
  display_cmd = sub_cmd.split("\n")[0][:50] + "..." if is_long else sub_cmd
65
62
  tool_msg_text = f"● Bash({display_cmd})"
@@ -91,8 +91,6 @@ class SampleConfirmView(discord.ui.View):
91
91
 
92
92
 
93
93
  class EnrollmentManager:
94
- """Owns /sound's recording flow - see _commit_enrollment."""
95
-
96
94
  def __init__(self, bot, voice_state: dict, play_audio, bot_settings: dict, save_bot_settings, logger):
97
95
  self.bot = bot
98
96
  self._voice_state = voice_state # shared with VoiceCog
@@ -109,11 +107,7 @@ class EnrollmentManager:
109
107
  def stop(self):
110
108
  self.cleanup_stale_enrollments.cancel()
111
109
 
112
- def is_enrolling(self, user_id: str) -> bool:
113
- return user_id in self._enrollment
114
-
115
110
  async def handle_voice_service_down(self):
116
- """Called when Node dies."""
117
111
  stale_user_ids = list(self._enrollment.keys())
118
112
  for uid in stale_user_ids:
119
113
  session = self._enrollment.pop(uid, None)
@@ -181,7 +175,6 @@ class EnrollmentManager:
181
175
  self.logger.warning(f"Failed to send enrollment status message: {e}")
182
176
 
183
177
  async def start_wake_word_recording(self, interaction: discord.Interaction, word: str) -> bool:
184
- """Called by /sound's wake_word param."""
185
178
  guild_id = interaction.guild_id
186
179
  user_id = str(interaction.user.id)
187
180
 
@@ -241,7 +234,6 @@ class EnrollmentManager:
241
234
  return True
242
235
 
243
236
  async def handle_enroll_sample(self, user_id: str, audio_bytes: bytes):
244
- """Called via /enroll_sample for each captured sample."""
245
237
  session = self._enrollment.get(user_id)
246
238
  if not session:
247
239
  return # stray sample - recording already finished/cancelled/expired
@@ -19,13 +19,11 @@ class SttSessionTracker:
19
19
  self._partial_msg = {}
20
20
 
21
21
  def is_active(self, guild_id: str) -> bool:
22
- """Whether the "stay awake" window is still open for this guild."""
23
22
  active_duration = self.bot_settings.get("active_timer", 60)
24
23
  last_active = self._last_active_time.get(str(guild_id), 0)
25
24
  return (time.time() - last_active) < active_duration
26
25
 
27
26
  def mark_tts_finished(self, guild_id: str):
28
- """Called via /tts_finished once the spoken reply finishes playing."""
29
27
  self.extend_active_window(str(guild_id))
30
28
 
31
29
  def extend_active_window(self, guild_id: str):
@@ -22,7 +22,3 @@ def set_status(platform: str, status: str, detail: str = "") -> None:
22
22
  "at": datetime.now().isoformat(),
23
23
  }
24
24
  atomic_write_json(path, data)
25
-
26
-
27
- def get_all() -> dict:
28
- return safe_load_json(_path(), {})
@@ -7,8 +7,6 @@ from core.atomic_io import atomic_write_json, safe_load_json
7
7
 
8
8
 
9
9
  class SessionManager:
10
- """Manages conversation state, async streaming queues, and user approval states."""
11
-
12
10
  def __init__(self, data_dir: Path):
13
11
  self.session_file = data_dir / "sessions.json"
14
12
  self.persistent_file = data_dir / "persistent_tools.json"
@@ -120,9 +118,6 @@ class SessionManager:
120
118
  if conv_id:
121
119
  self.active_approval_by_conv[conv_id] = approval_key
122
120
 
123
- def get_pending_approval(self, approval_key: str) -> asyncio.Future | None:
124
- return self.pending_approvals.get(approval_key)
125
-
126
121
  def get_pending_approval_by_conv(self, conv_id: str) -> asyncio.Future | None:
127
122
  """Looks up whichever approval is CURRENTLY active for a given
128
123
  conversation - for callers that only have the stable
@@ -1,6 +1,3 @@
1
- """Discord bot setup. Runs in the same process as Telegram (see main.py),
2
- which starts both concurrently when both platforms are enabled."""
3
-
4
1
  import asyncio
5
2
  import atexit
6
3
  import os
package/src/main_slack.py CHANGED
@@ -1,6 +1,3 @@
1
- """Slack bot setup. Runs in the same process as Discord/Telegram (see
2
- main.py), which starts all enabled platforms concurrently."""
3
-
4
1
  import asyncio
5
2
  import re
6
3
  import uuid
@@ -1,6 +1,3 @@
1
- """Telegram bot setup. Runs in the same process as Discord (see main.py),
2
- which starts both concurrently when both platforms are enabled."""
3
-
4
1
  import asyncio
5
2
  import uuid
6
3
  from datetime import datetime
@@ -1,6 +1,4 @@
1
- """Core messenger interface. Every backend (Discord now, Slack/Telegram
2
- later) implements MessengerAdapter; business logic never touches
3
- platform SDK types directly.
1
+ """Core messenger interface. Business logic never touches platform SDK types directly.
4
2
 
5
3
  Futures for approval/question prompts are owned by business logic, not
6
4
  the adapter - the same future can also be resolved by a typed reply,
@@ -17,7 +15,7 @@ from typing import Any
17
15
 
18
16
  @dataclass
19
17
  class IncomingAttachment:
20
- """A file on an inbound message; reader defers fetching bytes until needed."""
18
+ """reader defers fetching bytes until the attachment is actually read."""
21
19
 
22
20
  filename: str
23
21
  content_type: str | None
@@ -144,10 +142,6 @@ class MessengerAdapter(ABC):
144
142
  ) -> PromptHandle:
145
143
  raise NotImplementedError
146
144
 
147
- @property
148
- def supports_voice(self) -> bool:
149
- return isinstance(self, VoiceCapable)
150
-
151
145
 
152
146
  class VoiceCapable(ABC):
153
147
  @abstractmethod
@@ -1,7 +1,6 @@
1
- """Per-platform messenger adapter registry, populated once at startup by
2
- main.py. Both bots now share one process, so callers must say which
3
- platform they mean - either directly (voice/Discord-only code) or by
4
- looking up which platform a given thread_id/conversation belongs to."""
1
+ """Per-platform messenger adapter registry, populated once at startup by main.py. All platforms
2
+ share one process, so callers must say which platform they mean - either directly (voice/
3
+ Discord-only code) or by looking up which platform a thread_id belongs to."""
5
4
 
6
5
  from messengers.base import MessengerAdapter
7
6
 
@@ -24,12 +23,3 @@ def get_adapter_for_thread(thread_id: str) -> MessengerAdapter:
24
23
  session = session_manager.get_session(thread_id) or {}
25
24
  platform = session.get("platform", "discord") # pre-multi-platform sessions have no tag - assume discord
26
25
  return get_adapter_for_platform(platform)
27
-
28
-
29
- def get_adapter_for_conv_id(conv_id: str) -> MessengerAdapter | None:
30
- from config import session_manager
31
-
32
- for _thread_id, sess in session_manager.get_all_sessions().items():
33
- if sess.get("conversation_id") == conv_id:
34
- return get_adapter_for_platform(sess.get("platform", "discord"))
35
- return None
@@ -46,8 +46,8 @@ def decode_conversation_id(conversation_id: str) -> tuple[str, str] | None:
46
46
 
47
47
  def latest_channel_session(channel: str) -> tuple[str, dict] | None:
48
48
  """Most recently created Slack session in a channel - used as a fallback for un-threaded
49
- messages (users rarely bother clicking "Reply in thread") and for /model, /credit, which
50
- can't target a specific thread since Slack slash commands can't be invoked inside one."""
49
+ messages (users rarely bother clicking "Reply in thread") and for /model, which can't target
50
+ a specific thread since Slack slash commands can't be invoked inside one."""
51
51
  candidates = [
52
52
  (cid, s)
53
53
  for cid, s in session_manager.get_all_sessions().items()
@@ -59,8 +59,6 @@ def latest_channel_session(channel: str) -> tuple[str, dict] | None:
59
59
 
60
60
 
61
61
  class SlackConversationRef:
62
- """conversation_ref for Slack - a channel + the thread_ts all replies go under."""
63
-
64
62
  __slots__ = ("channel", "thread_ts")
65
63
 
66
64
  def __init__(self, channel: str, thread_ts: str):
@@ -77,8 +75,6 @@ class SlackConversationRef:
77
75
 
78
76
 
79
77
  class SlackMessageRef:
80
- """message_ref for edit_message - a specific message within a channel."""
81
-
82
78
  __slots__ = ("channel", "ts")
83
79
 
84
80
  def __init__(self, channel: str, ts: str):
@@ -1,6 +1,8 @@
1
1
  const originalLog = console.log;
2
2
  const originalError = console.error;
3
3
 
4
+ const debugEnabled = (process.env.LOG_LEVEL || 'INFO').toUpperCase() === 'DEBUG';
5
+
4
6
  function getTimestamp() {
5
7
  const now = new Date();
6
8
  const offset = now.getTimezoneOffset() * 60000;
@@ -14,3 +16,8 @@ console.log = function (...args) {
14
16
  console.error = function (...args) {
15
17
  originalError(`${getTimestamp()} ERROR Voice:`, ...args);
16
18
  };
19
+ console.debug = function (...args) {
20
+ if (debugEnabled) {
21
+ originalLog(`${getTimestamp()} DEBUG Voice:`, ...args);
22
+ }
23
+ };
@@ -148,7 +148,7 @@ function setupReceiver(connection, guildId, client) {
148
148
  const dynamicThreshold = isBotPlaying ? baseThreshold * 3 : baseThreshold;
149
149
  if (rms > dynamicThreshold) {
150
150
  if (interruptTTS(guildId)) {
151
- console.log(
151
+ console.debug(
152
152
  `[VAD] Loud voice detected (${Math.round(rms)}), interrupting TTS (Threshold: ${dynamicThreshold})`,
153
153
  );
154
154
  hasInterrupted = true;
@@ -236,15 +236,18 @@ function setupReceiver(connection, guildId, client) {
236
236
  const threshold = wakeThresholdFor(userId);
237
237
  wakeConfirmed = bestWakeScoreName !== null;
238
238
  matchedWakeWord = wakeConfirmed ? bestWakeScoreName : null;
239
- console.log(
240
- wakeConfirmed
241
- ? `[Wake] ${userId}: CONFIRMED (score ${bestWakeScore.toFixed(3)} for ` +
242
- `"${bestWakeScoreName}", threshold ${threshold})`
243
- : `[Wake] ${userId}: no match (score ${bestWakeScore.toFixed(3)}, ` +
244
- `threshold ${threshold}; diagnostic-only closeness ` +
245
- `${bestDiagScore.toFixed(3)} for "${bestDiagScoreName ?? 'n/a'}" - ` +
246
- `different scoring config, not directly comparable to the threshold)`,
247
- );
239
+ if (wakeConfirmed) {
240
+ console.log(
241
+ `[Wake] ${userId}: CONFIRMED (score ${bestWakeScore.toFixed(3)} for ` +
242
+ `"${bestWakeScoreName}", threshold ${threshold})`,
243
+ );
244
+ } else {
245
+ console.debug(
246
+ `[Wake] ${userId}: no match (threshold ${threshold}; diagnostic-only ` +
247
+ `closeness ${bestDiagScore.toFixed(3)} for "${bestDiagScoreName ?? 'n/a'}" - ` +
248
+ `different scoring config, not directly comparable to the threshold)`,
249
+ );
250
+ }
248
251
  }
249
252
 
250
253
  const pcmBuffer = Buffer.concat(chunks);
@@ -14,7 +14,7 @@ function interruptTTS(guildId) {
14
14
 
15
15
  if (player && player.state.status !== AudioPlayerStatus.Idle) {
16
16
  player.stop();
17
- console.log(`[VAD] Interrupted TTS in guild ${guildId}`);
17
+ console.debug(`[VAD] Interrupted TTS in guild ${guildId}`);
18
18
  interrupted = true;
19
19
  }
20
20