@juspay/neurolink 9.94.0 → 9.94.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/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## [9.94.1](https://github.com/juspay/neurolink/compare/v9.94.0...v9.94.1) (2026-07-19)
2
+
3
+ ### Bug Fixes
4
+
5
+ - **(cli):** play mp3 TTS on Linux via real decoders, not paplay ([#1138](https://github.com/juspay/neurolink/issues/1138)) ([a5a9a16](https://github.com/juspay/neurolink/commit/a5a9a16ef5fc71a084962faa031a74bb322155e2))
6
+ - **(skills):** fail closed on scoped skills without a scopeId ([#1139](https://github.com/juspay/neurolink/issues/1139)) ([5ae7b9f](https://github.com/juspay/neurolink/commit/5ae7b9f77168262fdff54c6df03bb3103d34d1cc)), closes [#1135](https://github.com/juspay/neurolink/issues/1135)
7
+
1
8
  ## [9.94.0](https://github.com/juspay/neurolink/compare/v9.93.2...v9.94.0) (2026-07-19)
2
9
 
3
10
  ### Features
@@ -1594,8 +1594,8 @@ Original size: ${ED(n)} | Externalized \u2014 use retrieve_context with artifact
1594
1594
  \u2022 Search through conversation history
1595
1595
  Supports filtering by role, pagination for large content, and regex search.
1596
1596
  To fetch an externalized artifact, provide \`artifactId\` (omit sessionId).`,inputSchema:m.object({sessionId:m.string().optional().describe("Session ID for conversation history retrieval. Required unless artifactId is provided."),artifactId:m.string().optional().describe("Artifact ID from an externalized MCP tool output (visible in the tool output as neurolinkArtifactId=<id>). When provided, returns the full stored payload directly."),messageId:m.string().optional().describe("Specific message ID to retrieve"),role:m.enum(["user","assistant","system","tool_call","tool_result"]).optional().describe("Filter messages by role"),lastN:m.number().int().positive().optional().describe("Retrieve the last N messages matching the filter"),offset:m.number().int().nonnegative().optional().describe("Character offset for paginated reading of large content (default: 0)"),limit:m.number().int().positive().optional().describe("Max characters to return per message (default: 50000)"),search:m.string().optional().describe("Regex pattern to search within message content. Returns matching lines with line numbers.")}),execute:async t=>Ke({name:"neurolink.memory.retrieve_context",tracer:_e.memory,attributes:{"memory.operation":t.artifactId?"artifact.fetch":"session.retrieve","memory.has_artifact_id":!!t.artifactId,"memory.has_session_id":!!t.sessionId,"memory.role":t.role??"any","memory.search":!!t.search}},async n=>u0r(t,r,e,n))}}}async function u0r(r,e,t,n){if(r.artifactId){if(!t)return g.warn("[MemoryRetrievalTools] retrieve_context called with artifactId but no ArtifactStore is configured"),n.setStatus({code:Me.ERROR,message:"Artifact store not configured"}),{error:"Artifact store not configured \u2014 mcp.outputLimits.strategy must be set to 'externalize' to use artifactId retrieval",artifactId:r.artifactId};let a=await Ge(t.retrieve(r.artifactId),1e4,new Error(`ArtifactStore.retrieve() timed out for artifact "${r.artifactId}"`));if(a===null)return n.setStatus({code:Me.ERROR,message:"Artifact not found or has expired"}),{error:"Artifact not found or has expired",artifactId:r.artifactId};let c=Math.min(r.limit??WHe,KHe),l=r.offset??0,u=a.slice(l,l+c);return n.setAttribute("memory.artifact_size",a.length),n.setAttribute("memory.returned_bytes",u.length),{artifactId:r.artifactId,content:u,totalSize:a.length,hasMore:l+c<a.length,offset:l,limit:c}}if(!r.sessionId)return n.setStatus({code:Me.ERROR,message:"sessionId is required when artifactId is not provided"}),{error:"sessionId is required when artifactId is not provided"};if(!e)return n.setStatus({code:Me.ERROR,message:"Memory manager not configured"}),{error:"Session history retrieval requires Redis conversation memory \u2014 enable mcp.conversationMemory with a Redis backend, or use artifactId to retrieve an externalized MCP tool output."};let o=xe.createSpan("memory","memory.retrieve",{"memory.operation":"retrieve","memory.store":"redis","memory.query":r.search||r.messageId||`lastN:${r.lastN??"all"}`}),s=Date.now(),i=String(r.sessionId);try{let a=await Ge(e.getSessionRaw(i),1e4,new Error(`getSessionRaw() timed out for session "${i}"`));if(!a){let p=xe.endSpan(o,2,`Session not found: ${i}`);return Ze().recordSpan(p),{error:"Session not found",sessionId:i}}let c=a.messages;if(r.messageId){let p=c.find(f=>f.id===r.messageId);if(!p){let f=xe.endSpan(o,2,`Message not found: ${r.messageId}`);return Ze().recordSpan(f),{error:"Message not found",messageId:r.messageId}}c=[p]}r.role&&(c=c.filter(p=>p.role===r.role)),r.lastN&&(c=c.slice(-r.lastN));let l=Math.min(r.limit??WHe,KHe),u=c.map(p=>{let f=p.content??"";if(r.search)try{let v=r.search;if(v.length>200)return{id:p.id,error:"Search pattern too long (max 200 chars)"};let T=v.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),S=new RegExp(T,"i"),C=f.split(`
1597
- `).map((R,P)=>({line:P+1,text:R})).filter(R=>S.test(R.text)).slice(0,l0r);return{id:p.id,role:p.role,tool:p.tool,matchCount:C.length,matches:C,totalSize:f.length}}catch{return{id:p.id,error:"Invalid regex pattern"}}let h=r.offset??0,y=h+l,x=f.slice(h,y);return{id:p.id,role:p.role,tool:p.tool,content:x,totalSize:f.length,hasMore:y<f.length}});o.durationMs=Date.now()-s;let d=xe.endSpan(o,1);return Ze().recordSpan(d),n.setAttribute("memory.message_count",u.length),{messages:u,totalMessages:u.length}}catch(a){o.durationMs=Date.now()-s;let c=xe.endSpan(o,2);return c.statusMessage=a instanceof Error?a.message:String(a),Ze().recordSpan(c),g.error("[MemoryRetrievalTools] Error retrieving context",{error:a instanceof Error?a.message:String(a)}),n.setStatus({code:Me.ERROR,message:a instanceof Error?a.message:String(a)}),n.recordException(a instanceof Error?a:new Error(String(a))),{error:"Failed to retrieve context"}}}var WHe,KHe,l0r,JHe=k(()=>{"use strict";Dr();Jn();q();It();xo();Cn();Vr();WHe=5e4,KHe=2e5,l0r=50});function Px(r){let{instructions:e,...t}=r;return t}function XHe(r){return[...r].sort((e,t)=>e.name<t.name?-1:e.name>t.name?1:0)}function Rde(r,e){let t=e.query?.toLowerCase(),n=e.tag?.toLowerCase(),o=r.filter(s=>!((s.status??"active")!=="active"||e.scopeId&&s.scope==="scoped"&&!(s.scopeIds??[]).includes(e.scopeId)||t&&!(s.name.toLowerCase().includes(t)||(s.displayName??"").toLowerCase().includes(t)||s.description.toLowerCase().includes(t))||n&&!(s.tags??[]).some(a=>a.toLowerCase().includes(n))));return e.limit!==void 0?o.slice(0,e.limit):o}function A3(r){let e=r.replace(/\\/g,"/");return e.length>0&&!e.startsWith("/")&&!e.split("/").some(t=>t===".."||t==="")}function kD(r,e){return!e||r.scope!=="scoped"?!0:(r.scopeIds??[]).includes(e)}function YHe(r){let e=r.match(/^[^.!?]*[.!?]/);return e?e[0].trim():r}function ZHe(r,e){if(r.length===0)return null;let t=i=>["<available_skills>",...r.map(c=>{let l=c.tags&&c.tags.length>0?` [tags: ${c.tags.join(", ")}]`:"";return`- ${c.name}: ${i(c)}${l}`}),"</available_skills>"].join(`
1598
- `),n=t(i=>i.description);if(n.length<=e)return n;let o=t(i=>YHe(i.description));if(o.length<=e)return o;let s=80;return t(i=>{let a=YHe(i.description);return a.length>s?`${a.slice(0,s-1)}\u2026`:a})}function QHe(r,e){if(r.length===0)return null;let t=r.slice(0,e),n=t.map(s=>{let i=s.displayName?`${s.name} (${s.displayName})`:s.name,a=s.tags&&s.tags.length>0?` [tags: ${s.tags.join(", ")}]`:"";return`- ${i}: ${s.description}${a}`}),o=r.length>t.length?`
1597
+ `).map((R,P)=>({line:P+1,text:R})).filter(R=>S.test(R.text)).slice(0,l0r);return{id:p.id,role:p.role,tool:p.tool,matchCount:C.length,matches:C,totalSize:f.length}}catch{return{id:p.id,error:"Invalid regex pattern"}}let h=r.offset??0,y=h+l,x=f.slice(h,y);return{id:p.id,role:p.role,tool:p.tool,content:x,totalSize:f.length,hasMore:y<f.length}});o.durationMs=Date.now()-s;let d=xe.endSpan(o,1);return Ze().recordSpan(d),n.setAttribute("memory.message_count",u.length),{messages:u,totalMessages:u.length}}catch(a){o.durationMs=Date.now()-s;let c=xe.endSpan(o,2);return c.statusMessage=a instanceof Error?a.message:String(a),Ze().recordSpan(c),g.error("[MemoryRetrievalTools] Error retrieving context",{error:a instanceof Error?a.message:String(a)}),n.setStatus({code:Me.ERROR,message:a instanceof Error?a.message:String(a)}),n.recordException(a instanceof Error?a:new Error(String(a))),{error:"Failed to retrieve context"}}}var WHe,KHe,l0r,JHe=k(()=>{"use strict";Dr();Jn();q();It();xo();Cn();Vr();WHe=5e4,KHe=2e5,l0r=50});function Px(r){let{instructions:e,...t}=r;return t}function XHe(r){return[...r].sort((e,t)=>e.name<t.name?-1:e.name>t.name?1:0)}function Rde(r,e){let t=e.query?.toLowerCase(),n=e.tag?.toLowerCase(),o=r.filter(s=>!((s.status??"active")!=="active"||s.scope==="scoped"&&(!e.scopeId||!(s.scopeIds??[]).includes(e.scopeId))||t&&!(s.name.toLowerCase().includes(t)||(s.displayName??"").toLowerCase().includes(t)||s.description.toLowerCase().includes(t))||n&&!(s.tags??[]).some(a=>a.toLowerCase().includes(n))));return e.limit!==void 0?o.slice(0,e.limit):o}function A3(r){let e=r.replace(/\\/g,"/");return e.length>0&&!e.startsWith("/")&&!e.split("/").some(t=>t===".."||t==="")}function kD(r,e){return r.scope!=="scoped"?!0:e?(r.scopeIds??[]).includes(e):!1}function YHe(r){let e=r.match(/^[^.!?]*[.!?]/);return e?e[0].trim():r}function ZHe(r,e){if(r.length===0)return null;let t=i=>["<available_skills>",...r.map(c=>{let l=c.tags&&c.tags.length>0?` [tags: ${c.tags.join(", ")}]`:"";return`- ${c.name}: ${i(c)}${l}`}),"</available_skills>"].join(`
1598
+ `),n=t(i=>i.description);if(n.length<=e)return n;let o=t(i=>YHe(i.description));if(o.length<=e)return o;let s=80;return t(i=>{let a=YHe(i.description);return a.length>s?`${a.slice(0,s-1)}\u2026`:a})}function QHe(r,e){if(r.length===0||e<=0)return null;let t=r.slice(0,e),n=t.map(s=>{let i=s.displayName?`${s.name} (${s.displayName})`:s.name,a=s.tags&&s.tags.length>0?` [tags: ${s.tags.join(", ")}]`:"";return`- ${i}: ${s.description}${a}`}),o=r.length>t.length?`
1599
1599
  (${r.length-t.length} more skills exist \u2014 use list_skills to discover them.)`:"";return["## Available Skills","The following team-defined skills (SOPs, playbooks, workflows) are available.","Before answering from general knowledge, check whether one applies to the user's request.","To use a skill, call the use_skill tool with its name to load the full instructions, then follow them exactly.","",...n].join(`
1600
1600
  `)+o}var JT=k(()=>{"use strict"});function Ide(r){let e=r.version??1;return{id:`skill-${bs()}`,role:"user",content:`[Skill loaded: ${r.name} v${e}]
1601
1601
 
@@ -6,32 +6,67 @@
6
6
  *
7
7
  * @module cli/utils/audioPlayer
8
8
  */
9
- import type { AudioFormat } from "../../lib/types/index.js";
9
+ import type { TTSAudioFormat, CliAudioPlayerCommand } from "../../lib/types/index.js";
10
10
  /**
11
11
  * Get the file extension for an audio format
12
12
  *
13
13
  * @param format - Audio format
14
14
  * @returns File extension string (e.g., "mp3", "wav")
15
15
  */
16
- export declare function getAudioExtension(format: AudioFormat): string;
16
+ export declare function getAudioExtension(format: TTSAudioFormat): string;
17
+ /**
18
+ * Escape a value for interpolation inside a single-quoted PowerShell string.
19
+ * PowerShell's single-quoted strings only treat `''` as a literal `'` — unlike
20
+ * shell/execFile argv, this string is parsed by powershell.exe itself, so an
21
+ * unescaped quote (e.g. a Windows username like `O'Brien` under `%TEMP%`)
22
+ * would break out of the string and let the remainder run as PS code.
23
+ */
24
+ export declare function escapePowerShellSingleQuoted(value: string): string;
25
+ /**
26
+ * Build the ordered list of player commands to try for a file.
27
+ *
28
+ * The list is tried in order until one succeeds; a missing binary (ENOENT) or a
29
+ * decode failure simply advances to the next candidate.
30
+ *
31
+ * The Linux ordering is the crux of the mp3-playback fix (#1138): `paplay`
32
+ * (PulseAudio) and `aplay` (ALSA) only decode libsndfile/PCM formats and
33
+ * **cannot** decode mp3/AAC. Since `--tts-format` defaults to mp3, compressed
34
+ * formats must lead with real decoders (ffplay/mpv/mpg123/cvlc); paplay/aplay
35
+ * remain only as last-resort fallbacks (and still work for wav/ogg).
36
+ *
37
+ * @param filePath - Path to the audio file
38
+ * @param format - Audio format
39
+ * @param platform - Platform id (defaults to `process.platform`; injectable for tests)
40
+ * @returns Ordered player commands; empty for unsupported platforms
41
+ */
42
+ export declare function getPlayerCandidates(filePath: string, format: TTSAudioFormat, platform?: NodeJS.Platform): CliAudioPlayerCommand[];
43
+ /**
44
+ * Build a helpful, format-aware error message when no player could decode the file.
45
+ *
46
+ * Exported for testing: the message a user sees when Linux mp3 playback finds no
47
+ * decoder must name the real fix (ffmpeg/mpv/mpg123/VLC or `--tts-format wav`),
48
+ * not the misleading "install PulseAudio" that paplay-only routing produced (#1138).
49
+ */
50
+ export declare function buildPlaybackErrorMessage(platform: NodeJS.Platform, format: TTSAudioFormat, attempts: string[]): string;
17
51
  /**
18
52
  * Play audio from a buffer using platform-specific CLI tools
19
53
  *
20
- * Writes the buffer to a temporary file, plays it using the appropriate
21
- * system audio player, and cleans up the temp file afterward.
54
+ * Writes the buffer to a temporary file, plays it using the first available
55
+ * system audio player that can decode the format, and cleans up the temp file.
22
56
  *
23
57
  * Supported platforms:
24
- * - macOS: uses `afplay` (built-in, supports mp3/wav/aac/flac)
25
- * - Linux: uses `paplay` for non-wav, `aplay` for wav
26
- * - Windows: uses PowerShell SoundPlayer (wav) or WMPlayer.OCX (mp3)
58
+ * - macOS: `afplay` (built-in; decodes mp3/wav/aac/flac)
59
+ * - Linux: real decoders (ffplay/mpv/mpg123/cvlc) for compressed formats,
60
+ * aplay/paplay for wav falling through the list until one works
61
+ * - Windows: PowerShell SoundPlayer (wav) or WMPlayer.OCX (compressed)
27
62
  *
28
63
  * @param buffer - Audio data buffer
29
64
  * @param format - Audio format (mp3, wav, ogg, opus)
30
- * @throws Error if playback fails or platform is unsupported
65
+ * @throws Error if no available player can play the audio, or platform is unsupported
31
66
  *
32
67
  * @example
33
68
  * ```typescript
34
69
  * await playAudio(audioBuffer, "mp3");
35
70
  * ```
36
71
  */
37
- export declare function playAudio(buffer: Buffer, format: AudioFormat): Promise<void>;
72
+ export declare function playAudio(buffer: Buffer, format: TTSAudioFormat): Promise<void>;
@@ -7,11 +7,25 @@
7
7
  * @module cli/utils/audioPlayer
8
8
  */
9
9
  import { execFile } from "node:child_process";
10
+ import { randomUUID } from "node:crypto";
10
11
  import fs from "node:fs";
11
12
  import os from "node:os";
12
13
  import path from "node:path";
13
14
  import { promisify } from "node:util";
14
15
  const execFileAsync = promisify(execFile);
16
+ /** Wall-clock cap on a single player invocation; a hung decoder must not block the CLI forever. */
17
+ const PLAYER_TIMEOUT_MS = 30_000;
18
+ /**
19
+ * Single source of truth for format -> temp-file-extension. Formats outside this
20
+ * playback-supported set (m4a, flac, webm, mp4, mpeg, mpga, pcm16) fall back to
21
+ * "mp3" — `playAudio` only ever receives the four formats below in practice.
22
+ */
23
+ const AUDIO_EXTENSIONS = {
24
+ mp3: "mp3",
25
+ wav: "wav",
26
+ ogg: "ogg",
27
+ opus: "opus",
28
+ };
15
29
  /**
16
30
  * Get the file extension for an audio format
17
31
  *
@@ -19,73 +33,145 @@ const execFileAsync = promisify(execFile);
19
33
  * @returns File extension string (e.g., "mp3", "wav")
20
34
  */
21
35
  export function getAudioExtension(format) {
22
- switch (format) {
23
- case "mp3":
24
- return "mp3";
25
- case "wav":
26
- return "wav";
27
- case "ogg":
28
- return "ogg";
29
- case "opus":
30
- return "opus";
31
- default:
32
- return "mp3";
33
- }
36
+ return AUDIO_EXTENSIONS[format] ?? "mp3";
37
+ }
38
+ /** ffmpeg's ffplay decodes every format we emit; quiet + auto-exit for CLI use. */
39
+ function ffplayCommand(filePath) {
40
+ return {
41
+ command: "ffplay",
42
+ args: ["-nodisp", "-autoexit", "-loglevel", "quiet", filePath],
43
+ };
34
44
  }
35
45
  /**
36
- * Get the platform-specific audio player command and arguments
46
+ * Escape a value for interpolation inside a single-quoted PowerShell string.
47
+ * PowerShell's single-quoted strings only treat `''` as a literal `'` — unlike
48
+ * shell/execFile argv, this string is parsed by powershell.exe itself, so an
49
+ * unescaped quote (e.g. a Windows username like `O'Brien` under `%TEMP%`)
50
+ * would break out of the string and let the remainder run as PS code.
51
+ */
52
+ export function escapePowerShellSingleQuoted(value) {
53
+ return value.replace(/'/g, "''");
54
+ }
55
+ /**
56
+ * Build the ordered list of player commands to try for a file.
57
+ *
58
+ * The list is tried in order until one succeeds; a missing binary (ENOENT) or a
59
+ * decode failure simply advances to the next candidate.
60
+ *
61
+ * The Linux ordering is the crux of the mp3-playback fix (#1138): `paplay`
62
+ * (PulseAudio) and `aplay` (ALSA) only decode libsndfile/PCM formats and
63
+ * **cannot** decode mp3/AAC. Since `--tts-format` defaults to mp3, compressed
64
+ * formats must lead with real decoders (ffplay/mpv/mpg123/cvlc); paplay/aplay
65
+ * remain only as last-resort fallbacks (and still work for wav/ogg).
37
66
  *
38
67
  * @param filePath - Path to the audio file
39
68
  * @param format - Audio format
40
- * @returns Object with command and args for execFile
69
+ * @param platform - Platform id (defaults to `process.platform`; injectable for tests)
70
+ * @returns Ordered player commands; empty for unsupported platforms
41
71
  */
42
- function getPlayerCommand(filePath, format) {
43
- const platform = process.platform;
72
+ export function getPlayerCandidates(filePath, format, platform = process.platform) {
73
+ const isWav = format === "wav";
44
74
  switch (platform) {
45
75
  case "darwin":
46
- return { command: "afplay", args: [filePath] };
76
+ // afplay decodes mp3/wav/aac/flac natively.
77
+ return [{ command: "afplay", args: [filePath] }];
47
78
  case "linux":
48
- if (format === "wav") {
49
- return { command: "aplay", args: [filePath] };
79
+ if (isWav) {
80
+ // WAV/PCM: ALSA and PulseAudio both decode it; ffplay as a backstop.
81
+ return [
82
+ { command: "aplay", args: [filePath] },
83
+ { command: "paplay", args: [filePath] },
84
+ ffplayCommand(filePath),
85
+ ];
86
+ }
87
+ // Compressed formats (mp3/ogg/opus): lead with decoders that actually
88
+ // handle them, then fall back to paplay/aplay (which work for some
89
+ // formats but never mp3).
90
+ return [
91
+ ffplayCommand(filePath),
92
+ {
93
+ command: "mpv",
94
+ args: ["--no-video", "--really-quiet", filePath],
95
+ },
96
+ ...(format === "mp3"
97
+ ? [{ command: "mpg123", args: ["-q", filePath] }]
98
+ : []),
99
+ {
100
+ command: "cvlc",
101
+ args: ["--play-and-exit", "--no-loop", "--intf", "dummy", filePath],
102
+ },
103
+ { command: "paplay", args: [filePath] },
104
+ { command: "aplay", args: [filePath] },
105
+ ];
106
+ case "win32": {
107
+ // filePath is embedded inside a single-quoted PS string below, so it must
108
+ // be escaped — a Windows username with an apostrophe (e.g. `O'Brien`,
109
+ // present in `%TEMP%`) would otherwise break out of the string.
110
+ const psPath = escapePowerShellSingleQuoted(filePath);
111
+ if (isWav) {
112
+ return [
113
+ {
114
+ command: "powershell",
115
+ args: [
116
+ "-NoProfile",
117
+ "-Command",
118
+ `(New-Object System.Media.SoundPlayer '${psPath}').PlaySync()`,
119
+ ],
120
+ },
121
+ ];
50
122
  }
51
- return { command: "paplay", args: [filePath] };
52
- case "win32":
53
- if (format === "wav") {
54
- return {
123
+ return [
124
+ {
55
125
  command: "powershell",
56
126
  args: [
57
127
  "-NoProfile",
58
128
  "-Command",
59
- `(New-Object System.Media.SoundPlayer '${filePath}').PlaySync()`,
129
+ `$player = New-Object -ComObject WMPlayer.OCX; $player.URL = '${psPath}'; $player.controls.play(); Start-Sleep -Seconds 1; while ($player.playState -eq 3) { Start-Sleep -Milliseconds 100 }; $player.close()`,
60
130
  ],
61
- };
62
- }
63
- return {
64
- command: "powershell",
65
- args: [
66
- "-NoProfile",
67
- "-Command",
68
- `$player = New-Object -ComObject WMPlayer.OCX; $player.URL = '${filePath}'; $player.controls.play(); Start-Sleep -Seconds 1; while ($player.playState -eq 3) { Start-Sleep -Milliseconds 100 }; $player.close()`,
69
- ],
70
- };
131
+ },
132
+ ];
133
+ }
71
134
  default:
72
- throw new Error(`Unsupported platform: ${platform}. Audio playback is supported on macOS, Linux, and Windows.`);
135
+ return [];
73
136
  }
74
137
  }
138
+ /**
139
+ * Build a helpful, format-aware error message when no player could decode the file.
140
+ *
141
+ * Exported for testing: the message a user sees when Linux mp3 playback finds no
142
+ * decoder must name the real fix (ffmpeg/mpv/mpg123/VLC or `--tts-format wav`),
143
+ * not the misleading "install PulseAudio" that paplay-only routing produced (#1138).
144
+ */
145
+ export function buildPlaybackErrorMessage(platform, format, attempts) {
146
+ const tried = attempts.length ? ` (tried: ${attempts.join("; ")})` : "";
147
+ if (platform === "linux") {
148
+ if (format === "wav") {
149
+ return `Could not play wav audio${tried}. Install ALSA (aplay) or PulseAudio (paplay).`;
150
+ }
151
+ // mpg123 only decodes mp3 (see getPlayerCandidates) — recommending it for
152
+ // ogg/opus failures would send users chasing a decoder that can't help.
153
+ if (format === "mp3") {
154
+ return `Could not play mp3 audio${tried}. paplay/aplay cannot decode mp3; install one of: ffmpeg (ffplay), mpv, mpg123, or VLC (cvlc) — or use --tts-format wav.`;
155
+ }
156
+ return `Could not play ${format} audio${tried}. Install ffmpeg (ffplay), mpv, or VLC (cvlc) — or use --tts-format wav.`;
157
+ }
158
+ return `Could not play ${format} audio${tried}. Ensure a system audio player is installed and available in PATH.`;
159
+ }
75
160
  /**
76
161
  * Play audio from a buffer using platform-specific CLI tools
77
162
  *
78
- * Writes the buffer to a temporary file, plays it using the appropriate
79
- * system audio player, and cleans up the temp file afterward.
163
+ * Writes the buffer to a temporary file, plays it using the first available
164
+ * system audio player that can decode the format, and cleans up the temp file.
80
165
  *
81
166
  * Supported platforms:
82
- * - macOS: uses `afplay` (built-in, supports mp3/wav/aac/flac)
83
- * - Linux: uses `paplay` for non-wav, `aplay` for wav
84
- * - Windows: uses PowerShell SoundPlayer (wav) or WMPlayer.OCX (mp3)
167
+ * - macOS: `afplay` (built-in; decodes mp3/wav/aac/flac)
168
+ * - Linux: real decoders (ffplay/mpv/mpg123/cvlc) for compressed formats,
169
+ * aplay/paplay for wav falling through the list until one works
170
+ * - Windows: PowerShell SoundPlayer (wav) or WMPlayer.OCX (compressed)
85
171
  *
86
172
  * @param buffer - Audio data buffer
87
173
  * @param format - Audio format (mp3, wav, ogg, opus)
88
- * @throws Error if playback fails or platform is unsupported
174
+ * @throws Error if no available player can play the audio, or platform is unsupported
89
175
  *
90
176
  * @example
91
177
  * ```typescript
@@ -94,36 +180,47 @@ function getPlayerCommand(filePath, format) {
94
180
  */
95
181
  export async function playAudio(buffer, format) {
96
182
  const ext = getAudioExtension(format);
97
- const tempFile = path.join(os.tmpdir(), `nl-tts-${Date.now()}.${ext}`);
183
+ // randomUUID (not just Date.now()) avoids two concurrent calls in the same
184
+ // millisecond racing on the same temp path.
185
+ const tempFile = path.join(os.tmpdir(), `nl-tts-${randomUUID()}.${ext}`);
98
186
  try {
99
187
  // Write audio buffer to temp file
100
188
  await fs.promises.writeFile(tempFile, buffer);
101
- const { command, args } = getPlayerCommand(tempFile, format);
102
- try {
103
- await execFileAsync(command, args);
189
+ const candidates = getPlayerCandidates(tempFile, format);
190
+ if (candidates.length === 0) {
191
+ throw new Error(`Unsupported platform: ${process.platform}. Audio playback is supported on macOS, Linux, and Windows.`);
104
192
  }
105
- catch (execError) {
106
- const err = execError;
107
- // Handle binary not found
108
- if (err.code === "ENOENT") {
109
- if (process.platform === "linux" && command === "paplay") {
110
- // Fallback to aplay on Linux
111
- try {
112
- await execFileAsync("aplay", [tempFile]);
113
- return;
114
- }
115
- catch (fallbackError) {
116
- const fbErr = fallbackError;
117
- if (fbErr.code === "ENOENT") {
118
- throw new Error("Neither paplay nor aplay found. Install PulseAudio (paplay) or ALSA (aplay) for audio playback.", { cause: fallbackError });
119
- }
120
- throw fallbackError;
121
- }
122
- }
123
- throw new Error(`Audio player '${command}' not found. Ensure it is installed and available in PATH.`, { cause: execError });
193
+ const attempts = [];
194
+ for (const { command, args } of candidates) {
195
+ try {
196
+ // `timeout` guards against a hung decoder (e.g. blocked on the audio
197
+ // device) so playback can't stall the CLI forever; SIGTERM lets the
198
+ // player shut down cleanly before Node force-kills it.
199
+ await execFileAsync(command, args, {
200
+ timeout: PLAYER_TIMEOUT_MS,
201
+ killSignal: "SIGTERM",
202
+ });
203
+ return; // Played successfully.
204
+ }
205
+ catch (execError) {
206
+ const err = execError;
207
+ // Every failure (missing binary, decode error, or timeout) is
208
+ // recorded and surfaced in the final error below — none are lost.
209
+ // We still advance to the next candidate rather than aborting on the
210
+ // first non-ENOENT failure: `command` existing but failing on this
211
+ // particular file (e.g. a corrupt stream, or one decoder lacking
212
+ // codec support) doesn't mean the *next* candidate will fail too,
213
+ // and failing fast here would defeat the multi-decoder fallback
214
+ // that #1138 exists to provide.
215
+ const detail = err.killed
216
+ ? `timed out after ${PLAYER_TIMEOUT_MS}ms`
217
+ : err.code === "ENOENT"
218
+ ? "not installed"
219
+ : (err.message ?? "failed");
220
+ attempts.push(`${command}: ${detail}`);
124
221
  }
125
- throw execError;
126
222
  }
223
+ throw new Error(buildPlaybackErrorMessage(process.platform, format, attempts));
127
224
  }
128
225
  finally {
129
226
  // Always clean up temp file
@@ -26,7 +26,13 @@ export declare function filterSkillIndex(items: SkillIndexItem[], query: SkillSe
26
26
  * keys are built by concatenation (S3, Redis) or path joining.
27
27
  */
28
28
  export declare function isSafeSkillResourcePath(resourcePath: string): boolean;
29
- /** Whether a skill is visible from the calling scope. */
29
+ /**
30
+ * Whether a skill is visible from the calling scope. Fails closed, matching
31
+ * `filterSkillIndex`: a scoped skill is hidden unless the caller supplies a
32
+ * `scopeId` that the skill explicitly lists. Omitting `scopeId` therefore
33
+ * yields global skills only — it must never expose a scoped skill by name
34
+ * through `use_skill` / `read_skill_resource` (#1139).
35
+ */
30
36
  export declare function isSkillVisibleInScope(skill: Pick<SkillDefinition, "scope" | "scopeIds">, scopeId?: string): boolean;
31
37
  /**
32
38
  * Render the `<available_skills>` block embedded in the use_skill tool
@@ -29,11 +29,14 @@ export function filterSkillIndex(items, query) {
29
29
  if ((item.status ?? "active") !== "active") {
30
30
  return false;
31
31
  }
32
- // Scope filter: global skills always pass; scoped skills require a
33
- // matching scopeId. When the caller provides no scopeId, scoped skills
34
- // still pass (curator semantics unscoped callers see everything).
35
- if (query.scopeId && item.scope === "scoped") {
36
- if (!(item.scopeIds ?? []).includes(query.scopeId)) {
32
+ // Scope filter: global skills always pass. Scoped skills require a
33
+ // scopeId that matches; when the caller supplies no scopeId we fail
34
+ // closed and exclude them, so a shared multi-tenant instance that forgets
35
+ // to pass a per-call scopeId never leaks one tenant's scoped skills to
36
+ // everyone (#1139). Callers that legitimately want a tenant's scoped
37
+ // skills must pass that tenant's scopeId.
38
+ if (item.scope === "scoped") {
39
+ if (!query.scopeId || !(item.scopeIds ?? []).includes(query.scopeId)) {
37
40
  return false;
38
41
  }
39
42
  }
@@ -67,11 +70,20 @@ export function isSafeSkillResourcePath(resourcePath) {
67
70
  !normalized.startsWith("/") &&
68
71
  !normalized.split("/").some((segment) => segment === ".." || segment === ""));
69
72
  }
70
- /** Whether a skill is visible from the calling scope. */
73
+ /**
74
+ * Whether a skill is visible from the calling scope. Fails closed, matching
75
+ * `filterSkillIndex`: a scoped skill is hidden unless the caller supplies a
76
+ * `scopeId` that the skill explicitly lists. Omitting `scopeId` therefore
77
+ * yields global skills only — it must never expose a scoped skill by name
78
+ * through `use_skill` / `read_skill_resource` (#1139).
79
+ */
71
80
  export function isSkillVisibleInScope(skill, scopeId) {
72
- if (!scopeId || skill.scope !== "scoped") {
81
+ if (skill.scope !== "scoped") {
73
82
  return true;
74
83
  }
84
+ if (!scopeId) {
85
+ return false;
86
+ }
75
87
  return (skill.scopeIds ?? []).includes(scopeId);
76
88
  }
77
89
  /** Trim a description to its first sentence (or the whole text when unsplittable). */
@@ -127,7 +139,10 @@ export function renderSkillListing(items, budgetChars) {
127
139
  * Returns null when nothing is visible so callers can skip injection.
128
140
  */
129
141
  export function formatSkillsPromptIndex(items, maxItems) {
130
- if (items.length === 0) {
142
+ // Nothing visible, or the index is explicitly disabled (maxItems <= 0):
143
+ // skip injection entirely rather than emit a header + "N more skills exist"
144
+ // note with zero skill lines (#1139).
145
+ if (items.length === 0 || maxItems <= 0) {
131
146
  return null;
132
147
  }
133
148
  const visible = items.slice(0, maxItems);
@@ -1675,3 +1675,12 @@ export type CliNetworkCommandArgs = BaseCommandArgs & {
1675
1675
  /** Show detailed information */
1676
1676
  detailed?: boolean;
1677
1677
  };
1678
+ /**
1679
+ * A single audio-player invocation for CLI TTS playback: a binary plus its
1680
+ * arguments for `execFile`. The player list is tried in order until one
1681
+ * succeeds (see `src/cli/utils/audioPlayer.ts`).
1682
+ */
1683
+ export type CliAudioPlayerCommand = {
1684
+ command: string;
1685
+ args: string[];
1686
+ };
@@ -26,7 +26,13 @@ export declare function filterSkillIndex(items: SkillIndexItem[], query: SkillSe
26
26
  * keys are built by concatenation (S3, Redis) or path joining.
27
27
  */
28
28
  export declare function isSafeSkillResourcePath(resourcePath: string): boolean;
29
- /** Whether a skill is visible from the calling scope. */
29
+ /**
30
+ * Whether a skill is visible from the calling scope. Fails closed, matching
31
+ * `filterSkillIndex`: a scoped skill is hidden unless the caller supplies a
32
+ * `scopeId` that the skill explicitly lists. Omitting `scopeId` therefore
33
+ * yields global skills only — it must never expose a scoped skill by name
34
+ * through `use_skill` / `read_skill_resource` (#1139).
35
+ */
30
36
  export declare function isSkillVisibleInScope(skill: Pick<SkillDefinition, "scope" | "scopeIds">, scopeId?: string): boolean;
31
37
  /**
32
38
  * Render the `<available_skills>` block embedded in the use_skill tool
@@ -29,11 +29,14 @@ export function filterSkillIndex(items, query) {
29
29
  if ((item.status ?? "active") !== "active") {
30
30
  return false;
31
31
  }
32
- // Scope filter: global skills always pass; scoped skills require a
33
- // matching scopeId. When the caller provides no scopeId, scoped skills
34
- // still pass (curator semantics unscoped callers see everything).
35
- if (query.scopeId && item.scope === "scoped") {
36
- if (!(item.scopeIds ?? []).includes(query.scopeId)) {
32
+ // Scope filter: global skills always pass. Scoped skills require a
33
+ // scopeId that matches; when the caller supplies no scopeId we fail
34
+ // closed and exclude them, so a shared multi-tenant instance that forgets
35
+ // to pass a per-call scopeId never leaks one tenant's scoped skills to
36
+ // everyone (#1139). Callers that legitimately want a tenant's scoped
37
+ // skills must pass that tenant's scopeId.
38
+ if (item.scope === "scoped") {
39
+ if (!query.scopeId || !(item.scopeIds ?? []).includes(query.scopeId)) {
37
40
  return false;
38
41
  }
39
42
  }
@@ -67,11 +70,20 @@ export function isSafeSkillResourcePath(resourcePath) {
67
70
  !normalized.startsWith("/") &&
68
71
  !normalized.split("/").some((segment) => segment === ".." || segment === ""));
69
72
  }
70
- /** Whether a skill is visible from the calling scope. */
73
+ /**
74
+ * Whether a skill is visible from the calling scope. Fails closed, matching
75
+ * `filterSkillIndex`: a scoped skill is hidden unless the caller supplies a
76
+ * `scopeId` that the skill explicitly lists. Omitting `scopeId` therefore
77
+ * yields global skills only — it must never expose a scoped skill by name
78
+ * through `use_skill` / `read_skill_resource` (#1139).
79
+ */
71
80
  export function isSkillVisibleInScope(skill, scopeId) {
72
- if (!scopeId || skill.scope !== "scoped") {
81
+ if (skill.scope !== "scoped") {
73
82
  return true;
74
83
  }
84
+ if (!scopeId) {
85
+ return false;
86
+ }
75
87
  return (skill.scopeIds ?? []).includes(scopeId);
76
88
  }
77
89
  /** Trim a description to its first sentence (or the whole text when unsplittable). */
@@ -127,7 +139,10 @@ export function renderSkillListing(items, budgetChars) {
127
139
  * Returns null when nothing is visible so callers can skip injection.
128
140
  */
129
141
  export function formatSkillsPromptIndex(items, maxItems) {
130
- if (items.length === 0) {
142
+ // Nothing visible, or the index is explicitly disabled (maxItems <= 0):
143
+ // skip injection entirely rather than emit a header + "N more skills exist"
144
+ // note with zero skill lines (#1139).
145
+ if (items.length === 0 || maxItems <= 0) {
131
146
  return null;
132
147
  }
133
148
  const visible = items.slice(0, maxItems);
@@ -1675,3 +1675,12 @@ export type CliNetworkCommandArgs = BaseCommandArgs & {
1675
1675
  /** Show detailed information */
1676
1676
  detailed?: boolean;
1677
1677
  };
1678
+ /**
1679
+ * A single audio-player invocation for CLI TTS playback: a binary plus its
1680
+ * arguments for `execFile`. The player list is tried in order until one
1681
+ * succeeds (see `src/cli/utils/audioPlayer.ts`).
1682
+ */
1683
+ export type CliAudioPlayerCommand = {
1684
+ command: string;
1685
+ args: string[];
1686
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "9.94.0",
3
+ "version": "9.94.1",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (connect any MCP server), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
6
6
  "author": {