tide-commander 1.92.0 → 1.93.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 (49) hide show
  1. package/dist/assets/{BossLogsModal-BTTK3SDM.js → BossLogsModal-CgILsm9V.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-BNbFVewB.js → BossSpawnModal-BdGy2pC2.js} +1 -1
  3. package/dist/assets/{ControlsModal-DnKdyYiv.js → ControlsModal-hFIzwgt7.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-BRFhRQka.js → DockerLogsModal-CDX9xVzn.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-BFnSppyy.js → EmbeddedEditor-2DZTiy_9.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-9uMrVRsz.js → GmailOAuthSetup-Cq1DpIPF.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-CLAy54pj.js → GoogleOAuthSetup-B1UOVYlZ.js} +1 -1
  8. package/dist/assets/{IframeModal-Cy5KHwqs.js → IframeModal-GZkqd6O6.js} +1 -1
  9. package/dist/assets/{IntegrationsPanel-C_a9pmU1.js → IntegrationsPanel-CBN_0nAs.js} +2 -2
  10. package/dist/assets/{LogViewerModal-D68ppO94.js → LogViewerModal-BrZrAYvt.js} +1 -1
  11. package/dist/assets/{MonitoringModal-CoZ0uDPq.js → MonitoringModal-bIVunlhs.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-Bbwdouj-.js → PM2LogsModal-Crs7Q2LK.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-Dqi_ofXl.js → RestoreArchivedAreaModal-9thnHbZ3.js} +1 -1
  14. package/dist/assets/{Scene2DCanvas-CnqwS4QZ.js → Scene2DCanvas-Bj7JLtW8.js} +1 -1
  15. package/dist/assets/{SceneManager-W7pJrfEW.js → SceneManager-C-ItjZnd.js} +1 -1
  16. package/dist/assets/{SkillsPanel-Cw-tVu6j.js → SkillsPanel-pTORcpQL.js} +1 -1
  17. package/dist/assets/{SlackMultiInstanceSetup-DddkR8Zr.js → SlackMultiInstanceSetup-FXuMr1vo.js} +1 -1
  18. package/dist/assets/{SpawnModal-DZODgZFD.js → SpawnModal-D3Pgos07.js} +1 -1
  19. package/dist/assets/{SubordinateAssignmentModal-BkhktU8i.js → SubordinateAssignmentModal-6au3Tv5w.js} +1 -1
  20. package/dist/assets/{TriggerManagerPanel-YcbVJ2Rc.js → TriggerManagerPanel-DletkqtL.js} +1 -1
  21. package/dist/assets/{WorkflowEditorPanel-ZadWONKw.js → WorkflowEditorPanel-DMu9KdjB.js} +1 -1
  22. package/dist/assets/{index-Ckn27_lZ.js → index-BGatvpcF.js} +1 -1
  23. package/dist/assets/{index-CN-XkbYd.js → index-BPox4QjF.js} +1 -1
  24. package/dist/assets/{index-CyPV_nq1.js → index-CQYizqu9.js} +1 -1
  25. package/dist/assets/{index-Bi8u2UlO.js → index-DAsi0YrR.js} +1 -1
  26. package/dist/assets/{index-DRsSsabz.js → index-DIsb3aYA.js} +1 -1
  27. package/dist/assets/{index-pqOlU6XT.js → index-Hl0I9IIt.js} +1 -1
  28. package/dist/assets/{index-CF1hOKKa.js → index-ZZtcJoNU.js} +33 -33
  29. package/dist/assets/{index-CtEyTmv-.js → index-_OjecyuG.js} +1 -1
  30. package/dist/assets/{index-cK048gKM.js → index-mEu7CM6i.js} +2 -2
  31. package/dist/assets/{main-jN7LzP4f.js → main-BUO6--48.js} +82 -80
  32. package/dist/assets/main-BfT_95fk.css +1 -0
  33. package/dist/assets/{web-DZfyqisM.js → web-BbNfUMzK.js} +1 -1
  34. package/dist/assets/{web-CfwxlY8x.js → web-CQsQBSkQ.js} +1 -1
  35. package/dist/assets/{web-DbhP1mtF.js → web-bKiHKTT6.js} +1 -1
  36. package/dist/index.html +2 -2
  37. package/dist/locales/en/terminal.json +2 -1
  38. package/dist/src/packages/server/integrations/gmail/gmail-client.js +82 -1
  39. package/dist/src/packages/server/integrations/gmail/gmail-trigger-handler.js +61 -1
  40. package/dist/src/packages/server/integrations/slack/slack-config.js +13 -0
  41. package/dist/src/packages/server/integrations/slack/slack-instance.js +1 -0
  42. package/dist/src/packages/server/integrations/slack/slack-polling-client.js +146 -0
  43. package/dist/src/packages/server/integrations/whatsapp/whatsapp-config.js +46 -0
  44. package/dist/src/packages/server/integrations/whatsapp/whatsapp-trigger-handler.js +77 -20
  45. package/dist/src/packages/server/routes/files.js +52 -6
  46. package/dist/src/packages/server/services/audio-transcription.js +165 -0
  47. package/dist/src/packages/server/services/trigger-service.js +8 -5
  48. package/package.json +1 -1
  49. package/dist/assets/main-CrGeO0Sc.css +0 -1
@@ -16,6 +16,7 @@ import { GroupNameCache } from './group-name-cache.js';
16
16
  import { WhatsAppClient } from './whatsapp-client.js';
17
17
  import { createLogger } from '../../utils/logger.js';
18
18
  import { downloadAttachment, formatAttachmentLine, MAX_ATTACHMENT_BYTES } from '../../services/attachment-downloader.js';
19
+ import { isWhisperAvailable, transcribeAudio } from '../../services/audio-transcription.js';
19
20
  import * as crypto from 'crypto';
20
21
  const log = createLogger('WhatsAppTrigger');
21
22
  // Module-level subscriber set fed by the in-process bridge whenever a normalized
@@ -137,7 +138,12 @@ export function createWhatsAppTriggerHandler(ctx) {
137
138
  if (msg.type === 'event' && (msg.event === 'message' || msg.event === 'message_create')) {
138
139
  const messageId = extractMessageId(msg.data);
139
140
  if (dedupe.isDuplicate(msg.sessionId, messageId)) {
140
- // Silent drop — duplicates are expected (upstream dual-fire).
141
+ // Duplicates are expected (upstream dual-fires `message_create`
142
+ // immediately followed by `message` for inbound DMs). Log at debug
143
+ // with the messageId hash so we can confirm in production when
144
+ // diagnosing rate-limit spikes — otherwise we silently drop and the
145
+ // operator has no visibility into how many duplicates are happening.
146
+ log.debug(`dedup.drop session=${msg.sessionId} event=${msg.event} messageId=${messageId ?? 'unknown'}`);
141
147
  return;
142
148
  }
143
149
  const config = loadConfig();
@@ -265,6 +271,24 @@ export function createWhatsAppTriggerHandler(ctx) {
265
271
  detail: String(err),
266
272
  };
267
273
  }
274
+ // Audio → whisper transcription. Runs inline so the trigger
275
+ // template renders with the transcription present (otherwise
276
+ // bolba/agents would fire with the audio file but no text). We
277
+ // gate on `mediaType==='audio'` (covers audioMessage / pttMessage
278
+ // / voiceNoteMessage via classifyFlatType) AND on whisper being
279
+ // installed — config.transcribeAudio defaults true but the probe
280
+ // is the real cutoff so machines without whisper just no-op.
281
+ if (payload.mediaType === 'audio' &&
282
+ payload.attachment &&
283
+ config.transcribeAudio !== false &&
284
+ (await isWhisperAvailable())) {
285
+ const text = await transcribeAudio(payload.attachment.path, {
286
+ model: config.whisperModel || 'medium',
287
+ language: config.whisperLanguage?.trim() || undefined,
288
+ });
289
+ if (text)
290
+ payload.audioTranscription = text;
291
+ }
268
292
  })()
269
293
  : Promise.resolve();
270
294
  void Promise.all([contactPromise, groupPromise, mediaPromise]).then(() => {
@@ -387,27 +411,27 @@ function extractContent(envelope, message) {
387
411
  const directMediaType = normalizeMediaType(pickString(envelope.mediaType) ?? pickString(envelope.type));
388
412
  const directMediaUrl = pickString(envelope.mediaUrl) ??
389
413
  pickString(envelope.media?.url);
390
- // 1a. The Baileys upstream we ship today emits a *flat* event shape where
391
- // the media signal lives at `data.type` (e.g. "imageMessage") with no
392
- // nested `message.imageMessage` proto, no `mediaUrl`, and `media: null`.
393
- // Detect that case and surface mediaType so the downloader's
394
- // `${baseUrl}/api/sessions/<sid>/messages/<mid>/media?download=true` URL
395
- // gets constructed from sessionId+messageId. mimetype/filename/size are
396
- // intentionally left undefined here — the downloader fills them from the
397
- // proxy's response headers (Content-Type, Content-Disposition).
414
+ // 1a. Flat-event detection for the Baileys upstream we ship today. Triggers
415
+ // when EITHER:
416
+ // (a) `data.hasMedia === true` (the upstream's explicit "has media" flag),
417
+ // OR
418
+ // (b) `data.type` is in a tight allowlist of known media-bearing Baileys
419
+ // proto names.
398
420
  //
399
- // Trigger conditions, in priority order:
400
- // 1. `data.type` matches `*Message` (image/video/audio/document/sticker
401
- // and all known variants like pttMessage, documentWithCaptionMessage,
402
- // videoNoteMessage, etc.)
403
- // 2. `data.hasMedia === true` regardless of how `data.type` is spelled.
404
- // We default unknown variants to `document` (the catch-all "it's a file"
405
- // category) so the download proceeds; the UI chip picks an icon from the
406
- // mimetype the downloader fills in.
407
- const flatType = pickString(envelope.type);
421
+ // We need both because the upstream isn't fully consistent: some media
422
+ // types arrive with `hasMedia: true` (images observed in the wild), others
423
+ // arrive with only the `type` field set and no `hasMedia` at all (documents
424
+ // in the regression David flagged on 2026-05-13).
425
+ //
426
+ // The allowlist is precise it EXCLUDES text/non-media types
427
+ // (`extendedTextMessage`, `conversation`, `protocolMessage`,
428
+ // `senderKeyDistributionMessage`, `reactionMessage`, …) that bit us with
429
+ // the earlier `/Message$/` suffix matcher: those produced bogus
430
+ // `[attachment-skipped: fetch-failed]` markers for plain text messages.
408
431
  const flatHasMedia = pickBoolean(envelope.hasMedia) === true;
409
- const flatTypeIsMessage = !!flatType && /Message$/i.test(flatType);
410
- if (flatTypeIsMessage || (flatHasMedia && !directMediaType && !directMediaUrl)) {
432
+ const flatType = pickString(envelope.type);
433
+ const flatTypeIsKnownMedia = !!flatType && KNOWN_MEDIA_TYPES.has(flatType);
434
+ if ((flatHasMedia || flatTypeIsKnownMedia) && !directMediaType && !directMediaUrl) {
411
435
  return {
412
436
  body: directBody,
413
437
  mediaType: classifyFlatType(flatType),
@@ -533,6 +557,25 @@ function normalizeMediaType(value) {
533
557
  return v;
534
558
  return undefined;
535
559
  }
560
+ /**
561
+ * Tight allowlist of Baileys proto names that genuinely carry media bytes.
562
+ * Excludes text/control events (`extendedTextMessage`, `conversation`,
563
+ * `protocolMessage`, `senderKeyDistributionMessage`, `reactionMessage`, …)
564
+ * which were the false positives that broke text messages when we used a
565
+ * loose `/Message$/` suffix check. If the upstream emits a new media kind
566
+ * not in this list, add it here.
567
+ */
568
+ const KNOWN_MEDIA_TYPES = new Set([
569
+ 'imageMessage',
570
+ 'videoMessage',
571
+ 'audioMessage',
572
+ 'documentMessage',
573
+ 'documentWithCaptionMessage',
574
+ 'stickerMessage',
575
+ 'pttMessage',
576
+ 'videoNoteMessage',
577
+ 'voiceNoteMessage',
578
+ ]);
536
579
  /**
537
580
  * Map an upstream `data.type` (Baileys-style, suffixed `Message`) to one of
538
581
  * the supported MediaType buckets. Known variants:
@@ -755,6 +798,9 @@ export const whatsappTriggerHandler = {
755
798
  // Build the attachments block (single line — WA messages carry at most one
756
799
  // media payload). Use the downloaded local path when present; otherwise
757
800
  // emit a `[attachment-skipped: …]` marker if we tried and failed.
801
+ // When an audio transcription is available we append it on its own line
802
+ // so existing templates referencing `{{whatsapp.attachmentsBlock}}` see
803
+ // both the file chip and the spoken text without any template edits.
758
804
  let attachmentsBlock = '';
759
805
  if (msg.attachment) {
760
806
  attachmentsBlock = formatAttachmentLine(msg.attachment);
@@ -764,6 +810,10 @@ export const whatsappTriggerHandler = {
764
810
  const sizeMb = typeof sk.size === 'number' ? `${Math.round(sk.size / (1024 * 1024))}MB` : 'unknown';
765
811
  attachmentsBlock = `[attachment-skipped: ${sk.reason} name=${sk.filename ?? 'unknown'} size=${sizeMb}${sk.detail ? ` detail=${sk.detail}` : ''}]`;
766
812
  }
813
+ if (msg.audioTranscription) {
814
+ const tline = `[audio transcription: ${msg.audioTranscription}]`;
815
+ attachmentsBlock = attachmentsBlock ? `${attachmentsBlock}\n${tline}` : tline;
816
+ }
767
817
  return {
768
818
  'whatsapp.from': msg.from,
769
819
  'whatsapp.fromName': fromName,
@@ -782,6 +832,10 @@ export const whatsappTriggerHandler = {
782
832
  'whatsapp.attachmentName': msg.attachment?.filename ?? msg.mediaFilename ?? '',
783
833
  'whatsapp.attachmentSize': msg.attachment ? String(msg.attachment.size) : (msg.mediaSize !== undefined ? String(msg.mediaSize) : ''),
784
834
  'whatsapp.attachmentsBlock': attachmentsBlock,
835
+ // Whisper transcription for audio messages. Empty string when
836
+ // transcription was disabled, whisper is missing, or the audio failed
837
+ // to transcribe — never undefined so template substitution is clean.
838
+ 'whatsapp.audioTranscription': msg.audioTranscription ?? '',
785
839
  'whatsapp.timestamp': new Date(msg.timestamp).toISOString(),
786
840
  };
787
841
  },
@@ -805,6 +859,9 @@ export const whatsappTriggerHandler = {
805
859
  else if (msg.mediaType) {
806
860
  mediaSuffix = `\n[${msg.mediaType} attachment${msg.mediaUrl ? ` ${msg.mediaUrl}` : ''}]`;
807
861
  }
862
+ if (msg.audioTranscription) {
863
+ mediaSuffix += `\n[audio transcription: ${msg.audioTranscription}]`;
864
+ }
808
865
  return `WhatsApp message ${verb} in ${channel}\nFrom: ${sender}\nSession: ${msg.sessionId}\nTime: ${new Date(msg.timestamp).toISOString()}\n\n${msg.body}${mediaSuffix}`;
809
866
  },
810
867
  };
@@ -786,6 +786,47 @@ router.post('/rename', (req, res) => {
786
786
  res.status(500).json({ error: err.message });
787
787
  }
788
788
  });
789
+ // POST /api/files/create - Create a new file or folder inside a target directory
790
+ router.post('/create', (req, res) => {
791
+ try {
792
+ const { parentDir, name, isDirectory } = req.body;
793
+ const validatedParent = parentDir && ensureAbsoluteExistingPath(parentDir);
794
+ if (!validatedParent) {
795
+ res.status(400).json({ error: 'Invalid or missing parent directory' });
796
+ return;
797
+ }
798
+ const parentStats = fs.statSync(validatedParent);
799
+ if (!parentStats.isDirectory()) {
800
+ res.status(400).json({ error: 'Parent path is not a directory' });
801
+ return;
802
+ }
803
+ const nextName = (name || '').trim();
804
+ if (!nextName || nextName === '.' || nextName === '..') {
805
+ res.status(400).json({ error: 'Invalid name' });
806
+ return;
807
+ }
808
+ if (nextName.includes('/') || nextName.includes('\\')) {
809
+ res.status(400).json({ error: 'Name must not contain path separators' });
810
+ return;
811
+ }
812
+ const destinationPath = path.join(validatedParent, nextName);
813
+ if (fs.existsSync(destinationPath)) {
814
+ res.status(409).json({ error: 'Target already exists' });
815
+ return;
816
+ }
817
+ if (isDirectory) {
818
+ fs.mkdirSync(destinationPath);
819
+ }
820
+ else {
821
+ fs.writeFileSync(destinationPath, '', { flag: 'wx' });
822
+ }
823
+ res.json({ success: true, path: destinationPath, isDirectory: !!isDirectory });
824
+ }
825
+ catch (err) {
826
+ log.error(' Failed to create path:', err);
827
+ res.status(500).json({ error: err.message });
828
+ }
829
+ });
789
830
  // POST /api/files/copy - Copy file/folder into target directory
790
831
  router.post('/copy', (req, res) => {
791
832
  try {
@@ -2861,10 +2902,10 @@ router.post('/git-fetch', async (req, res) => {
2861
2902
  res.status(500).json({ error: err.message });
2862
2903
  }
2863
2904
  });
2864
- // POST /api/files/delete - Delete a file from disk
2905
+ // POST /api/files/delete - Delete a file or (with recursive: true) a directory
2865
2906
  router.post('/delete', (req, res) => {
2866
2907
  try {
2867
- const filePath = req.body.path;
2908
+ const { path: filePath, recursive } = req.body;
2868
2909
  if (!filePath || typeof filePath !== 'string') {
2869
2910
  res.status(400).json({ error: 'Missing path parameter' });
2870
2911
  return;
@@ -2879,14 +2920,19 @@ router.post('/delete', (req, res) => {
2879
2920
  }
2880
2921
  const stat = fs.statSync(filePath);
2881
2922
  if (stat.isDirectory()) {
2882
- res.status(400).json({ error: 'Cannot delete directories' });
2883
- return;
2923
+ if (!recursive) {
2924
+ res.status(400).json({ error: 'Pass recursive: true to delete directories' });
2925
+ return;
2926
+ }
2927
+ fs.rmSync(filePath, { recursive: true, force: true });
2928
+ }
2929
+ else {
2930
+ fs.unlinkSync(filePath);
2884
2931
  }
2885
- fs.unlinkSync(filePath);
2886
2932
  res.json({ success: true });
2887
2933
  }
2888
2934
  catch (err) {
2889
- log.error(' Failed to delete file:', err);
2935
+ log.error(' Failed to delete path:', err);
2890
2936
  res.status(500).json({ error: err.message });
2891
2937
  }
2892
2938
  });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Audio Transcription Service
3
+ *
4
+ * Wraps the OpenAI `whisper` CLI when it's installed on the host, so inbound
5
+ * audio attachments from integrations (WhatsApp voice notes, Slack audio
6
+ * uploads, …) can be converted to text in addition to the file itself.
7
+ *
8
+ * Design constraints:
9
+ * - SAFE NO-OP when whisper is missing. The integration pipeline must keep
10
+ * working unchanged on machines without it — the only effect is that
11
+ * `audioTranscription` stays undefined.
12
+ * - NEVER THROWS. Errors are logged and surfaced as `null`.
13
+ * - Whisper detection is cached for the process lifetime; installing or
14
+ * removing whisper requires a server restart to re-detect (matches the
15
+ * behavior of every other CLI-probe we have).
16
+ * - We invoke whisper via `--output_format txt --output_dir <same dir as
17
+ * audio>` and read the resulting `<base>.txt` back. Stdout could be
18
+ * parsed instead but file-based is simpler and avoids buffering large
19
+ * progress logs in memory.
20
+ */
21
+ import { spawn } from 'child_process';
22
+ import * as fs from 'fs/promises';
23
+ import * as path from 'path';
24
+ import { createLogger } from '../utils/logger.js';
25
+ const log = createLogger('AudioTranscription');
26
+ let whisperAvailable = null;
27
+ /**
28
+ * Probe for the `whisper` CLI. Cached after the first call.
29
+ */
30
+ export async function isWhisperAvailable() {
31
+ if (whisperAvailable !== null)
32
+ return whisperAvailable;
33
+ whisperAvailable = await detectWhisper();
34
+ return whisperAvailable;
35
+ }
36
+ function detectWhisper() {
37
+ return new Promise((resolve) => {
38
+ let proc;
39
+ try {
40
+ proc = spawn('whisper', ['--help'], { stdio: 'ignore' });
41
+ }
42
+ catch {
43
+ log.debug('whisper not found on PATH; audio transcription disabled');
44
+ resolve(false);
45
+ return;
46
+ }
47
+ proc.on('error', () => {
48
+ log.debug('whisper not found on PATH; audio transcription disabled');
49
+ resolve(false);
50
+ });
51
+ proc.on('exit', (code) => {
52
+ const ok = code === 0;
53
+ if (ok) {
54
+ log.log('whisper detected on PATH; audio transcription enabled');
55
+ }
56
+ else {
57
+ log.debug(`whisper --help exited ${code}; audio transcription disabled`);
58
+ }
59
+ resolve(ok);
60
+ });
61
+ });
62
+ }
63
+ /**
64
+ * Transcribe an audio file via whisper. Returns the transcribed text, or
65
+ * `null` on any failure (whisper missing, non-zero exit, timeout, missing
66
+ * file). Never throws.
67
+ */
68
+ export async function transcribeAudio(audioPath, opts = {}) {
69
+ if (!(await isWhisperAvailable()))
70
+ return null;
71
+ try {
72
+ await fs.access(audioPath);
73
+ }
74
+ catch {
75
+ log.warn(`transcribeAudio: file not found ${audioPath}`);
76
+ return null;
77
+ }
78
+ const outputDir = path.dirname(audioPath);
79
+ const baseName = path.basename(audioPath, path.extname(audioPath));
80
+ const txtPath = path.join(outputDir, `${baseName}.txt`);
81
+ // Idempotent cache hit: if a previous run already produced the txt file,
82
+ // skip the (slow) whisper invocation. Same dedup intent as the attachment
83
+ // downloader's existingSizeMatches.
84
+ try {
85
+ const stat = await fs.stat(txtPath);
86
+ if (stat.isFile() && stat.size > 0) {
87
+ const cachedText = (await fs.readFile(txtPath, 'utf-8')).trim();
88
+ if (cachedText) {
89
+ log.debug(`transcribeAudio: cache-hit ${txtPath}`);
90
+ return cachedText;
91
+ }
92
+ }
93
+ }
94
+ catch { /* no cache, fall through */ }
95
+ const model = opts.model ?? 'medium';
96
+ const timeoutMs = opts.timeoutMs ?? 5 * 60_000;
97
+ const args = [
98
+ audioPath,
99
+ '--model', model,
100
+ '--output_format', 'txt',
101
+ '--output_dir', outputDir,
102
+ '--verbose', 'False',
103
+ // `--fp16 False` keeps the FP16-on-CPU UserWarning out of stderr and
104
+ // matches what whisper itself falls back to on CPU-only hosts anyway.
105
+ '--fp16', 'False',
106
+ ];
107
+ if (opts.language) {
108
+ args.push('--language', opts.language);
109
+ }
110
+ const started = Date.now();
111
+ return new Promise((resolve) => {
112
+ let proc;
113
+ try {
114
+ proc = spawn('whisper', args, { stdio: ['ignore', 'pipe', 'pipe'] });
115
+ }
116
+ catch (err) {
117
+ log.warn(`transcribeAudio: spawn threw: ${err}`);
118
+ resolve(null);
119
+ return;
120
+ }
121
+ let stderr = '';
122
+ let killed = false;
123
+ const timer = setTimeout(() => {
124
+ killed = true;
125
+ try {
126
+ proc.kill('SIGTERM');
127
+ }
128
+ catch { /* ignore */ }
129
+ log.warn(`transcribeAudio: timeout after ${timeoutMs}ms for ${audioPath}`);
130
+ }, timeoutMs);
131
+ proc.stderr?.on('data', (chunk) => {
132
+ stderr += chunk.toString();
133
+ if (stderr.length > 8192)
134
+ stderr = stderr.slice(-4096);
135
+ });
136
+ proc.on('error', (err) => {
137
+ clearTimeout(timer);
138
+ log.warn(`transcribeAudio: spawn error: ${err}`);
139
+ resolve(null);
140
+ });
141
+ proc.on('exit', async (code) => {
142
+ clearTimeout(timer);
143
+ if (killed) {
144
+ resolve(null);
145
+ return;
146
+ }
147
+ if (code !== 0) {
148
+ log.warn(`transcribeAudio: whisper exit ${code} for ${audioPath}: ${stderr.slice(-500)}`);
149
+ resolve(null);
150
+ return;
151
+ }
152
+ try {
153
+ const raw = await fs.readFile(txtPath, 'utf-8');
154
+ const text = raw.trim();
155
+ const tookSec = ((Date.now() - started) / 1000).toFixed(1);
156
+ log.log(`transcribed ${audioPath} in ${tookSec}s (${text.length} chars, model=${model}${opts.language ? ` lang=${opts.language}` : ''})`);
157
+ resolve(text || null);
158
+ }
159
+ catch (err) {
160
+ log.warn(`transcribeAudio: failed to read ${txtPath}: ${err}`);
161
+ resolve(null);
162
+ }
163
+ });
164
+ });
165
+ }
@@ -491,9 +491,12 @@ export async function fireTrigger(id, variables, opts) {
491
491
  log.error(`Cannot fire unknown trigger: ${id}`);
492
492
  return;
493
493
  }
494
- // Rate limit check
495
- if (!checkRateLimit(id)) {
496
- log.warn(`Trigger ${trigger.name} rate-limited (>${RATE_LIMIT_MAX}/min)`);
494
+ // Rate limit check. Per-trigger `rateLimitPerMinute` overrides the global
495
+ // default (10/min). Set to 0 / negative to disable entirely for this
496
+ // trigger (used by high-volume local sources like personal WA/Slack).
497
+ const effectiveLimit = typeof trigger.rateLimitPerMinute === 'number' ? trigger.rateLimitPerMinute : RATE_LIMIT_MAX;
498
+ if (effectiveLimit > 0 && !checkRateLimit(id, effectiveLimit)) {
499
+ log.warn(`Trigger ${trigger.name} rate-limited (>${effectiveLimit}/min)`);
497
500
  return;
498
501
  }
499
502
  const startTime = Date.now();
@@ -575,7 +578,7 @@ export async function fireTrigger(id, variables, opts) {
575
578
  }
576
579
  }
577
580
  // ─── Rate Limiting ───
578
- function checkRateLimit(triggerId) {
581
+ function checkRateLimit(triggerId, limit = RATE_LIMIT_MAX) {
579
582
  const now = Date.now();
580
583
  let timestamps = rateLimitMap.get(triggerId);
581
584
  if (!timestamps) {
@@ -587,7 +590,7 @@ function checkRateLimit(triggerId) {
587
590
  while (timestamps.length > 0 && timestamps[0] < cutoff) {
588
591
  timestamps.shift();
589
592
  }
590
- if (timestamps.length >= RATE_LIMIT_MAX) {
593
+ if (timestamps.length >= limit) {
591
594
  return false;
592
595
  }
593
596
  timestamps.push(now);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tide-commander",
3
- "version": "1.92.0",
3
+ "version": "1.93.0",
4
4
  "description": "Visual multi-agent orchestrator and manager for Claude Code with 3D/2D interface",
5
5
  "repository": {
6
6
  "type": "git",