linkgravity 1.2.0 → 1.2.1

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
@@ -14,6 +14,7 @@ const color = {
14
14
  green: '\x1b[32m',
15
15
  cyan: '\x1b[36m',
16
16
  yellow: '\x1b[33m',
17
+ red: '\x1b[31m',
17
18
  dim: '\x1b[2m',
18
19
  };
19
20
 
@@ -76,31 +77,71 @@ function runPm2(args, silent = true) {
76
77
  // Only strips the FIRST bracket group if present, so aiohttp's second
77
78
  // "[INFO ]" bracket (not a timestamp) is left alone.
78
79
  const TIMESTAMP_PREFIX = /^\[?\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}\]?\s*/;
80
+ // loguru's colorize=True puts an ANSI code before the timestamp digits, breaking the '^' anchor above.
81
+ // eslint-disable-next-line no-control-regex
82
+ const ANSI_ESCAPE = /\x1b\[[0-9;]*m/g;
83
+
84
+ const LEVEL_COLOR = {
85
+ DEBUG: color.dim,
86
+ INFO: color.cyan,
87
+ WARNING: color.yellow,
88
+ ERROR: color.red,
89
+ CRITICAL: color.red,
90
+ };
91
+
92
+ // Recolors the level word ourselves so Python (loguru) and Node (plain console.log) lines match.
93
+ function colorizeLevel(line) {
94
+ return line.replace(/\b(DEBUG|INFO|WARNING|ERROR|CRITICAL)\b/, (match) => {
95
+ const c = LEVEL_COLOR[match];
96
+ return c ? `${c}${match}${color.reset}` : match;
97
+ });
98
+ }
79
99
 
80
100
  function runPm2LogsClean(args, showStamps = false) {
81
101
  const cp = spawn('npx', ['-y', 'pm2', ...args], { cwd: path.join(__dirname, '..') });
82
102
 
83
- const filterAndPrint = (data) => {
84
- const lines = data.toString().split('\n');
85
- for (const line of lines) {
86
- if (line.trim().length === 0) continue;
87
- if (
88
- line.includes('In-memory PM2') ||
89
- line.includes('pm2 update') ||
90
- line.includes('[TAILING]') ||
91
- line.includes('.pm2/logs/lgy') ||
92
- line.includes('In memory PM2 version') ||
93
- line.includes('Local PM2 version') ||
94
- line.match(/^>+ /)
95
- ) {
96
- continue;
97
- }
98
- console.log(showStamps ? line : line.replace(TIMESTAMP_PREFIX, ''));
103
+ const printLine = (line) => {
104
+ if (line.trim().length === 0) return;
105
+ if (
106
+ line.includes('In-memory PM2') ||
107
+ line.includes('pm2 update') ||
108
+ line.includes('[TAILING]') ||
109
+ line.includes('.pm2/logs/lgy') ||
110
+ line.includes('In memory PM2 version') ||
111
+ line.includes('Local PM2 version') ||
112
+ line.match(/^>+ /)
113
+ ) {
114
+ return;
99
115
  }
116
+ const clean = line.replace(ANSI_ESCAPE, '');
117
+ const displayLine = colorizeLevel(showStamps ? clean : clean.replace(TIMESTAMP_PREFIX, ''));
118
+ console.log(displayLine);
100
119
  };
101
120
 
102
- cp.stdout.on('data', filterAndPrint);
103
- cp.stderr.on('data', filterAndPrint);
121
+ // A line can arrive split across two 'data' events, so buffer until '\n' is seen.
122
+ function makeChunkHandler() {
123
+ let buffer = '';
124
+ const handler = (data) => {
125
+ buffer += data.toString();
126
+ const lines = buffer.split('\n');
127
+ buffer = lines.pop(); // last element: '' if buffer ended in '\n', else the incomplete tail
128
+ for (const line of lines) printLine(line);
129
+ };
130
+ handler.flush = () => {
131
+ if (buffer) printLine(buffer);
132
+ buffer = '';
133
+ };
134
+ return handler;
135
+ }
136
+
137
+ const stdoutHandler = makeChunkHandler();
138
+ const stderrHandler = makeChunkHandler();
139
+ cp.stdout.on('data', stdoutHandler);
140
+ cp.stderr.on('data', stderrHandler);
141
+ cp.on('close', () => {
142
+ stdoutHandler.flush();
143
+ stderrHandler.flush();
144
+ });
104
145
  }
105
146
 
106
147
  function verifyStartup() {
@@ -168,7 +209,14 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
168
209
  runPm2(['restart', 'lgy', '--update-env']);
169
210
  verifyStartup();
170
211
  } else if (cmd === 'logs') {
171
- let args = process.argv.slice(3);
212
+ const SHORT_FLAGS = ['-f', '-n', '-t'];
213
+ let args = process.argv.slice(3).flatMap((arg) => {
214
+ // Only split bare combined short flags (e.g. "-fn" -> "-f", "-n"), not "--long" flags.
215
+ if (!/^-[a-z]{2,}$/.test(arg)) return [arg];
216
+ const chars = arg.slice(1).split('');
217
+ if (!chars.every((c) => SHORT_FLAGS.includes(`-${c}`))) return [arg];
218
+ return chars.map((c) => `-${c}`);
219
+ });
172
220
  let pm2Args = ['logs', 'lgy'];
173
221
  let isFollow = false;
174
222
  let showStamps = false;
@@ -265,8 +313,7 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
265
313
  if (restartResult.status === 0) {
266
314
  verifyStartup();
267
315
  } else if ((restartResult.stderr || '').toString().includes('not found')) {
268
- // Daemon wasn't running before the update - start it fresh instead
269
- // of reporting a restart that never had anything to restart.
316
+ // Wasn't running before the update - start fresh instead of a false "restarted".
270
317
  info("Daemon wasn't running - starting it fresh...");
271
318
  runPm2(['start', botPath, '--interpreter', pythonExe, '--name', 'lgy']);
272
319
  verifyStartup();
package/bin/setup.js CHANGED
@@ -278,9 +278,7 @@ async function runSetup() {
278
278
  } else {
279
279
  const stderr = (restartResult.stderr || '').toString();
280
280
  if (stderr.includes('not found')) {
281
- // Nothing to restart yet (first-ever setup, or pm2's process list was
282
- // reset e.g. after a reboot without `pm2 save`) - start it instead of
283
- // reporting a false "restarted" success.
281
+ // Nothing to restart yet - start it instead of a false "restarted".
284
282
  const botPath = path.join(__dirname, '..', 'src', 'main.py');
285
283
  const startResult = spawnSync(
286
284
  'npx',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkgravity",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Discord bot bridge for the Antigravity (agy) CLI, with voice interaction support",
5
5
  "scripts": {
6
6
  "postinstall": "node npm-scripts/postinstall.js",
@@ -273,10 +273,7 @@ class GeneralCog(commands.Cog):
273
273
  prev_tts.cancel()
274
274
  session_manager.remove_tts_task(thread_id)
275
275
 
276
- # A killed turn leaves the conversation in an unknown state -
277
- # reusing conv_id risks silently hanging on the next message.
278
- # conversation_id must be cleared too, not just status: the
279
- # text path only treats a session as pending when both are unset.
276
+ # Must clear conversation_id too, not just status - pending requires both unset.
280
277
  session_manager.set_session(thread_id, {**session, "status": "pending", "conversation_id": None})
281
278
 
282
279
  from core.agy_runner import stop_active_process
@@ -44,8 +44,7 @@ class VoiceCog(commands.Cog):
44
44
  self.save_bot_settings = save_bot_settings
45
45
  self.logger = logger
46
46
  self._voice_state = {}
47
- # guild_id -> in-flight handle_stt_input task, if any. A new
48
- # utterance cancels the previous one so replies can't race out of order.
47
+ # guild_id -> in-flight handle_stt_input task; a new utterance cancels the previous one.
49
48
  self._active_turns = {}
50
49
  self.enrollment = EnrollmentManager(
51
50
  bot, self._voice_state, self._play_audio, self.bot_settings, self.save_bot_settings, self.logger
@@ -292,8 +291,7 @@ class VoiceCog(commands.Cog):
292
291
  updated = []
293
292
  wake_word_pending = False
294
293
  if wake_word is not None:
295
- # Deferred: wake_words only gets set once 5 samples are
296
- # recorded and confirmed (see EnrollmentManager), not here.
294
+ # Deferred - wake_words is set later once 5 samples are recorded (see EnrollmentManager).
297
295
  wake_word_pending = True
298
296
  if active_times is not None:
299
297
  self.bot_settings["active_timer"] = active_times
@@ -383,8 +381,7 @@ class VoiceCog(commands.Cog):
383
381
  await self.stt_session.clear_partial_msg(str(guild_id))
384
382
  return
385
383
 
386
- # Node runs STT itself before calling this endpoint, so this is
387
- # already-recognized text, not raw audio - keeps STT off every VAD hit.
384
+ # Node runs STT itself before this call, so 'text' is already-recognized, not raw audio.
388
385
  text = data.get("text")
389
386
 
390
387
  thread_id = self._voice_state.get(str(guild_id))
@@ -407,10 +404,7 @@ class VoiceCog(commands.Cog):
407
404
  import difflib
408
405
  import re
409
406
 
410
- # Wake detection is Node's audio-based Rustpotter detector's job.
411
- # No text-similarity fallback: an unenrolled user (or a failed
412
- # .rpw build - see EnrollmentManager._build_rustpotter_reference)
413
- # just can't wake the bot until that's fixed.
407
+ # Wake detection is Node's Rustpotter detector's job - no text-similarity fallback.
414
408
  is_waking_up = bool(data.get("wake_confirmed"))
415
409
  matched_wake_word = data.get("matched_wake_word")
416
410
  is_active = self.stt_session.is_active(str(guild_id))
@@ -444,8 +438,7 @@ class VoiceCog(commands.Cog):
444
438
  if prefix_similarity >= 0.6:
445
439
  text_to_ai = " ".join(words[wake_word_count:]).strip()
446
440
 
447
- # No text resemblance at all + short wake word (less acoustic
448
- # signal, more false-wakes) -> require a closer match to trust it.
441
+ # Short wake words have less acoustic signal (more false-wakes) -> need a closer match.
449
442
  wake_syllables = len(re.sub(r"[^\w가-힣]", "", matched_wake_word or ""))
450
443
  min_prefix_similarity = 0.55 if wake_syllables <= 2 else 0.35
451
444
  if is_waking_up and prefix_similarity is not None and prefix_similarity < min_prefix_similarity:
@@ -458,9 +451,7 @@ class VoiceCog(commands.Cog):
458
451
 
459
452
  self.logger.debug(f"STT recognized: {text} -> AI: {text_to_ai}")
460
453
 
461
- # A previous in-flight turn for this guild is now stale - cancel
462
- # it, kill its agy process (like /stop), and stop its playback,
463
- # rather than letting two turns race to completion.
454
+ # A stale in-flight turn for this guild - cancel it, kill its agy process, stop playback.
464
455
  prev_task = self._active_turns.get(str(guild_id))
465
456
  if prev_task and not prev_task.done():
466
457
  prev_task.cancel()
package/src/config.py CHANGED
@@ -19,8 +19,7 @@ DEFAULT_LGY_CONFIG = {
19
19
  "voice_threshold": 3000,
20
20
  "tts_voice": "ko-KR-SunHiNeural",
21
21
  "tts_enabled": True,
22
- # Sticky default for new /new sessions - set whenever /model succeeds,
23
- # so a new thread doesn't fall back to agy's own settings.json model.
22
+ # Sticky default for /new sessions, set whenever /model succeeds.
24
23
  "default_model": "",
25
24
  }
26
25
 
@@ -94,10 +93,7 @@ def is_allowed_session_channel(channel) -> bool:
94
93
 
95
94
  TMP_FILE_DIR = WORKSPACE_DIR / "tmp-files"
96
95
  TMP_VOICE_DIR = WORKSPACE_DIR / "tmp-voice"
97
- # Per-user wake-word recordings + built .rpw reference (see
98
- # EnrollmentManager in cogs/voice/enrollment.py). No folder/.rpw yet means
99
- # "not enrolled" - falls back to transcribing everything and matching
100
- # bot_settings["wake_words"] as text.
96
+ # Per-user wake-word recordings + built .rpw reference (see EnrollmentManager).
101
97
  WAKE_REF_DIR = WORKSPACE_DIR / "wake_refs"
102
98
 
103
99
  TMP_FILE_DIR.mkdir(parents=True, exist_ok=True)